refactor(cctv): separate camera source and scene components

This commit is contained in:
Sameh Khamis committed 2026-09-13 15:02:32 -07:00
1 parent ca5f5fff93
commit 519bc6c362
32 files changed
+6873 -4835

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
export function createGround({ state: layerState, services, parts, source }) {
const { resolveGroundFloorCells } = services.ground;
const { resolveEllipsoidalGround } = services.terrain;
/**
* Task 5 (height-datum fix): maps the scene's `globe.show` flag to the surface
* regime key the per-camera ground cache is keyed by (spec §2 "cache by
* surface regime", collapsed to two keys — ion World Terrain and Re:Earth
* globe terrain get the same handling):
*
* - `google-3d` — photoreal stack: globe hidden, the visible Google 3D
* tileset IS the surface → one-shot scene sampling refines.
* - `terrain-globe` — any globe stack: the Re:Earth point-height prior IS the
* resolution (zero scene queries).
*
* Only an explicit `false` (the photoreal stack hides the globe) selects
* `google-3d`; undefined/null (no viewer / torn down) must fall to the regime
* that never touches the scene. Pure — exported for the unit suite.
* @param {boolean|undefined|null} globeShow - `viewer.scene.globe.show`.
* @returns {'google-3d'|'terrain-globe'}
*/
function surfaceRegimeKey(globeShow) {
return globeShow === false ? 'google-3d' : 'terrain-globe';
}
/**
* Task 5: the surface regime the scene is CURRENTLY rendering, derived live
* from `globe.show` (mapStackController's `_activatePhotoreal` /
* `_activateGlobeStack` flip exactly this flag). Reading scene state directly
* — rather than caching the map-stack id — means the regime is correct even
* for stack changes this module never got an event for.
* @returns {'google-3d'|'terrain-globe'}
*/
function currentSurfaceRegime() {
return surfaceRegimeKey(layerState._viewer?.scene?.globe?.show);
}
/**
* Task 5: the record's ellipsoidal ground PRIOR (Re:Earth point-height batch,
* `record.groundPrior.ellipsoid`), falling back to the catalog's fabricated
* orthometric `groundElevationM` only when the prior batch hasn't landed yet.
* This is the value that replaces every previous
* `Number(camera.groundElevationM) || 0` ground fallback — it alone lifts
* London's cameras from the fabricated 15 m to ~52.7 m ellipsoidal in every
* regime, on first paint.
* @param {Object} record - Camera record.
* @returns {number} Ellipsoidal ground altitude in metres.
*/
function groundPriorAltFor(record) {
const prior = record?.groundPrior?.ellipsoid;
return Number.isFinite(prior)
? prior
: Number(record?.camera?.groundElevationM) || 0;
}
/**
* Task 5: whether the record's one-shot ground resolution has completed for
* the given regime (per-regime latch — replaces the old boolean
* `groundResolved`).
* @param {Object} record - Camera record.
* @param {string} [regime] - Defaults to the current surface regime.
* @returns {boolean}
*/
function isGroundResolved(record, regime = currentSurfaceRegime()) {
return record?.groundResolved?.[regime] === true;
}
/**
* Ground altitude used for pure geometry recomputes: the given regime's
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
* in google-3d, the DEM/prior in terrain-globe) when it exists, else the prior
* itself. Never queries the scene.
* @param {Object} record - Camera record.
* @param {string} [regime] - Defaults to the current surface regime.
* @returns {number} Ground altitude in metres.
*/
function groundAltFor(record, regime = currentSurfaceRegime()) {
const cached = record?.groundSamples?.[regime];
return Number.isFinite(cached) ? cached : groundPriorAltFor(record);
}
/**
* Re-arms a record for a fresh one-shot ground resolution after a GENUINE
* pose change (an explicit user select/move or manual calibration edit).
* Clears the CURRENT regime's resolved latch so the next real pass in
* updateRecordGeometry always applies, then the record re-freezes. Never
* called on a timer.
*
* The cached `groundSamples` entries are deliberately KEPT (only the latch is
* cleared). They are the record's "has ever resolved" memory: the B9c
* fallback guard in updateRecordGeometry reads the google-3d entry so a
* rearmed camera whose tiles are mid-stream (e.g. select → flyTo →
* tilesLoaded false) is not yanked back to prior/catalog heights before its
* fresh shared floor lands. In the terrain-globe regime the re-arm is
* effectively free: the next pass re-latches from the DEM/prior with zero
* scene queries.
* @param {Object} record - Camera record.
*/
function rearmGroundResolution(record) {
if (!record) return;
record.groundResolved[currentSurfaceRegime()] = false;
}
/**
* Resolves a user-moved ground anchor exactly once at commit. The synchronous
* pass uses a warm shared floor immediately, or preserves the pre-drag floor
* while the new cell is cold. A revision and coordinate check prevent an
* older asynchronous release from rewriting a newer edit.
* @param {Object} record - Camera record whose lat/lon just committed.
*/
function resolveCommittedGroundAnchor(record) {
if (!record?.camera) return;
record.calibrationGroundResolveCount =
(record.calibrationGroundResolveCount || 0) + 1;
const revision = (record.calibrationGroundRevision || 0) + 1;
record.calibrationGroundRevision = revision;
const point = { lat: record.camera.lat, lon: record.camera.lon };
rearmGroundResolution(record);
parts.geometry.updateRecordGeometry(record);
if (isGroundResolved(record)) return;
resolveGroundFloorCells([point]).then(() => {
if (layerState._recordById.get(record.camera.id) !== record) return;
if (record.calibrationGroundRevision !== revision) return;
if (record.camera.lat !== point.lat || record.camera.lon !== point.lon)
return;
rearmGroundResolution(record);
parts.geometry.updateRecordGeometry(record);
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
});
}
/**
* Task 5: batches every catalog camera's coords through the Re:Earth
* ellipsoidal ground resolver (`/api/terrain/heights` proxy — network-cached,
* chunked, geoid fallback; NOT a scene query). The catalog's orthometric
* `groundElevationM` rides along as `sourceOrthometricM` so the geoid
* fallback chain is meaningful where the catalog value is real (Caltrans).
* Never rejects — a total failure resolves null and geometry stays on
* catalog fallbacks (no worse than pre-Task-5).
* @param {Object[]} catalog - Camera objects (post-ensureCameraPose).
* @returns {Promise<Array<{ellipsoid:number, source:string}>|null>}
*/
async function resolveGroundPriors(catalog) {
try {
const coords = catalog.map((camera) => {
const ortho = Number(camera.groundElevationM);
return {
lat: camera.lat,
lon: camera.lon,
...(Number.isFinite(ortho) ? { sourceOrthometricM: ortho } : {}),
};
});
return await resolveEllipsoidalGround(coords);
} catch (error) {
console.warn(
'[Data:CCTV] ground-prior batch failed (keeping catalog fallbacks):',
error?.message || error,
);
return null;
}
}
/**
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
* queries:
* - terrain-globe regime: the prior IS the resolution → re-run the
* resolution (updateRecordGeometry latches it) for every record.
* - google-3d regime: records still awaiting a shared floor move from the
* catalog fallback onto the exact prior; records already holding a shared
* mesh/DEM floor keep it untouched.
* Guarded per record against a torn-down/re-inited layer (records are only
* touched while they are still the live catalog entries).
* @param {Object[]} records - The record array captured at init time.
* @param {Array<{ellipsoid:number, source:string}>} priors - Aligned by index.
*/
function applyLateGroundPriors(records, priors) {
if (!Array.isArray(records) || !Array.isArray(priors)) return;
let applied = 0;
for (let i = 0; i < records.length && i < priors.length; i++) {
const record = records[i];
const prior = priors[i];
if (!record || !prior || !Number.isFinite(prior.ellipsoid)) continue;
// Stale-record guard: init() may have re-run (destroy/init cycle) while
// the batch was in flight — only touch records still live in the map.
if (layerState._recordById.get(record.camera.id) !== record) continue;
record.groundPrior = prior;
// Keep the cheap pre-enable altitude consistent for records whose
// geometry hasn't been applied yet (applyFrustumGeometry overwrites it).
if (!record.frustumPositions) {
record.camera.absoluteHeightM =
prior.ellipsoid + record.camera.mountHeightM;
}
const regime = currentSurfaceRegime();
if (regime === 'terrain-globe') {
// Prior IS the resolution — re-latch onto the fresh value.
parts.geometry.updateRecordGeometry(record, { sampleGround: false });
applied += 1;
} else if (!Number.isFinite(record.groundSamples['google-3d'])) {
// Still awaiting the shared floor: snap interim geometry onto the exact
// prior (pure recompute; the shared cell may refine later).
parts.geometry.applyFrustumGeometry(record, prior.ellipsoid);
applied += 1;
}
}
if (applied) parts.presentation.notifyListeners();
}
/**
* Task 5: surface-regime change handler ('gev:map-stack-changed'
* CustomEvent, dispatched by main.js from MapStackController.onChange). The
* surface HEIGHT at a camera differs between regimes (a photogrammetric
* deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
* no-ops):
* 1. every record's geometry recomputes IMMEDIATELY from the new regime's
* resolution — cached sample if that regime has one, else the Re:Earth
* prior (never blank, zero scene queries);
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
* update()'s existing event-driven machinery refines records that never
* took their sample, through the same staggered queue.
* Event-driven only — never called on a timer.
*/
function handleMapStackChanged() {
if (!layerState._viewer || !layerState._records.length) return;
const regime = currentSurfaceRegime();
if (regime === layerState._lastAppliedRegime) return;
layerState._lastAppliedRegime = regime;
for (const record of layerState._records) {
if (regime === 'terrain-globe') {
// Prior IS the resolution — latch it (zero scene queries).
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
record.groundResolved['terrain-globe'] = true;
}
const ground = groundAltFor(record, regime);
// Skip the entity rewrite when the applied ground already matches (e.g.
// entering google-3d before any sample: prior → prior is a no-op).
if (
record.frustumGeometry &&
Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001
) {
continue;
}
parts.geometry.applyFrustumGeometry(record, ground);
}
if (regime === 'google-3d') {
// Fresh google-3d session: let update()'s ONE-SHOT completion pass
// re-enqueue records without an accepted sample once the (re-shown)
// tileset reports tilesLoaded. Records already sampled in a previous
// google-3d session keep their cached resolution — 0 new samples, well
// under the ≤1-per-(camera, session) ceiling.
layerState._tilesReadyReenqueued = false;
}
parts.presentation.notifyListeners();
}
return {
surfaceRegimeKey,
currentSurfaceRegime,
groundPriorAltFor,
isGroundResolved,
groundAltFor,
rearmGroundResolution,
resolveCommittedGroundAnchor,
resolveGroundPriors,
applyLateGroundPriors,
handleMapStackChanged,
};
}