fix(foundry): isolate HGeoZone projection faults

This commit is contained in:
Codex
2026-07-22 10:04:18 +03:00
parent 309cddef73
commit ca26702047
5 changed files with 189 additions and 27 deletions
+87 -26
View File
@@ -58,6 +58,7 @@ import {
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
@@ -109,7 +110,7 @@ export type MapProviderStatus = {
imagery: MapProviderState;
terrain: MapProviderState;
buildings: MapProviderState;
errors: Partial<Record<"imagery" | "terrain" | "buildings", string>>;
errors: Partial<Record<"imagery" | "terrain" | "buildings" | "projection", string>>;
};
type IonAssetEndpoint = {
@@ -366,19 +367,18 @@ type HGeoZoneProjectionLayer = {
};
function hGeoZoneRingPositions(ring: Array<[number, number]>) {
const first = ring[0];
const last = ring[ring.length - 1];
const openRing = first && last && first[0] === last[0] && first[1] === last[1]
? ring.slice(0, -1)
: ring;
return openRing.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
return normalizeHGeoZoneRing(ring)
.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
}
function hGeoZoneHierarchy(polygon: Array<Array<[number, number]>>) {
return new PolygonHierarchy(
hGeoZoneRingPositions(polygon[0]),
polygon.slice(1).map((ring) => new PolygonHierarchy(hGeoZoneRingPositions(ring))),
);
const outer = hGeoZoneRingPositions(polygon[0]);
if (outer.length < 3) return null;
const holes = polygon.slice(1)
.map((ring) => hGeoZoneRingPositions(ring))
.filter((ring) => ring.length >= 3)
.map((ring) => new PolygonHierarchy(ring));
return new PolygonHierarchy(outer, holes);
}
function hGeoZoneBatches<T>(items: T[]) {
@@ -395,6 +395,29 @@ function removeHGeoZoneLayer(viewer: Viewer, layer: HGeoZoneProjectionLayer) {
}
}
function hGeoZoneFaultKey(bindingId: string, geometryKey: string) {
return `${bindingId}\u0000${geometryKey}`;
}
function quarantineHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
faultedGeometryKeys: Set<string>,
) {
if (!layers.size) return false;
const hasPendingGeometry = [...layers.values()].some((layer) => (
[...layer.fills, ...layer.outlines].some((batch) => !batch.primitive.ready)
));
if (!hasPendingGeometry) return false;
for (const [bindingId, layer] of layers) {
faultedGeometryKeys.add(hGeoZoneFaultKey(bindingId, layer.geometryKey));
removeHGeoZoneLayer(viewer, layer);
}
layers.clear();
viewer.scene.requestRender();
return true;
}
function syncHGeoZoneVisibility(viewer: Viewer, layers: Map<string, HGeoZoneProjectionLayer>) {
const cameraHeight = Number(viewer.camera.positionCartographic?.height || 0);
for (const layer of layers.values()) {
@@ -477,6 +500,7 @@ function syncHGeoZoneLayers(
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedGeometryKeys: Set<string>,
) {
const activeBindings = new Set(bindings.filter((binding) => binding.slotId === "zones").map((binding) => binding.bindingId));
for (const [bindingId, layer] of layers) {
@@ -520,22 +544,26 @@ function syncHGeoZoneLayers(
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
for (const [polygonIndex, polygon] of polygons.entries()) {
const hierarchy = hGeoZoneHierarchy(polygon);
if (!hierarchy) continue;
const instanceBase = `${baseEntityId}:part:${polygonIndex}`;
const fillPickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:fill`,
};
fillParts.push({ pickId: fillPickId, hierarchy: hGeoZoneHierarchy(polygon), color: fillColor });
fillParts.push({ pickId: fillPickId, hierarchy, color: fillColor });
geometryMembers.push(fillPickId.instanceId);
styleMembers.push(`${fillPickId.instanceId}:${resolvedStyle?.id ?? "default"}:${fillColor.toCssHexString()}:${fillColor.alpha}`);
for (const [ringIndex, ring] of polygon.entries()) {
const positions = hGeoZoneRingPositions(ring);
if (positions.length < 3) continue;
const outlinePickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:ring:${ringIndex}`,
};
outlineParts.push({ pickId: outlinePickId, positions: hGeoZoneRingPositions(ring), color: outlineColor });
outlineParts.push({ pickId: outlinePickId, positions, color: outlineColor });
geometryMembers.push(outlinePickId.instanceId);
}
}
@@ -549,6 +577,11 @@ function syncHGeoZoneLayers(
layers.delete(binding.bindingId);
continue;
}
if (faultedGeometryKeys.has(hGeoZoneFaultKey(binding.bindingId, geometryKey))) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (current?.geometryKey === geometryKey) {
current.hideCameraHeightMeters = hideCameraHeightMeters;
if (current.styleKey !== styleKey) {
@@ -586,6 +619,7 @@ function syncRuntimeDataSources(
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedHGeoZoneGeometryKeys: Set<string>,
) {
const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points" || binding.slotId === "zones")
@@ -714,7 +748,7 @@ function syncRuntimeDataSources(
|| (profile && profile.target.variant !== "surface-fill")
) continue;
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const outerRing = polygons[0]?.[0] ?? [];
const outerRing = normalizeHGeoZoneRing(polygons[0]?.[0] ?? []);
if (!outerRing.length) continue;
wanted.add(baseEntityId);
const labelAnchor = outerRing.reduce(
@@ -753,7 +787,14 @@ function syncRuntimeDataSources(
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
}
}
syncHGeoZoneLayers(viewer, hGeoZoneLayers, bindings, presentationProfiles, presentationFilters);
syncHGeoZoneLayers(
viewer,
hGeoZoneLayers,
bindings,
presentationProfiles,
presentationFilters,
faultedHGeoZoneGeometryKeys,
);
viewer.scene.requestRender();
}
@@ -912,6 +953,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const rebuildGridRef = useRef<(() => void) | null>(null);
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
const hGeoZoneLayersRef = useRef(new Map<string, HGeoZoneProjectionLayer>());
const faultedHGeoZoneGeometryKeysRef = useRef(new Set<string>());
const presentationRef = useRef(presentation);
const runtimeBindingsRef = useRef(runtimeBindings);
const presentationProfilesRef = useRef(presentationProfiles);
@@ -1354,6 +1396,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
runtimeBindings,
presentationProfiles,
presentationFilters,
faultedHGeoZoneGeometryKeysRef.current,
);
}
}, [presentationFilters, presentationProfiles, runtimeBindings]);
@@ -1433,14 +1476,6 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true;
syncRuntimeDataSources(
viewer,
runtimeDataSourcesRef.current,
hGeoZoneLayersRef.current,
runtimeBindingsRef.current,
presentationProfilesRef.current,
presentationFiltersRef.current,
);
viewerRef.current = viewer;
terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
@@ -1487,9 +1522,22 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
stopSpiralAnimation("render_error");
const raw = error instanceof Error ? error.message : "cesium_render_error";
const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
reportProvider("imagery", "error", safe);
reportProvider("terrain", "error", safe);
reportProvider("buildings", "error", safe);
const projectionQuarantined = quarantineHGeoZoneLayers(
viewer!,
hGeoZoneLayersRef.current,
faultedHGeoZoneGeometryKeysRef.current,
);
if (projectionQuarantined) {
// A domain projection is not a live-provider or TileCache failure.
// Quarantine the exact binding/cursor geometry and resume the base
// scene; a new cursor or renderer restart may try a corrected set.
providerStatus.errors.projection = `hgeozone_quarantined:${safe}`;
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
} else {
reportProvider("imagery", "error", safe);
reportProvider("terrain", "error", safe);
reportProvider("buildings", "error", safe);
}
if (renderRecoveryScheduled) return;
renderRecoveryScheduled = true;
window.setTimeout(() => {
@@ -1501,6 +1549,18 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
viewer.scene.requestRender();
}, 0);
});
// Projection primitives are created only after the scene-level fault
// boundary is active. Their asynchronous Cesium workers must never be
// able to fail before the offending domain layer can be quarantined.
syncRuntimeDataSources(
viewer,
runtimeDataSourcesRef.current,
hGeoZoneLayersRef.current,
runtimeBindingsRef.current,
presentationProfilesRef.current,
presentationFiltersRef.current,
faultedHGeoZoneGeometryKeysRef.current,
);
if (config?.gatewayReady) {
// Do not serialize provider startup. A failure in Bing imagery is
@@ -1648,6 +1708,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
rebuildGridRef.current = null;
runtimeDataSourcesRef.current.clear();
hGeoZoneLayersRef.current.clear();
faultedHGeoZoneGeometryKeysRef.current.clear();
};
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
@@ -0,0 +1,5 @@
export type HGeoZoneCoordinate = [number, number];
export declare function normalizeHGeoZoneRing(
ring: HGeoZoneCoordinate[],
): HGeoZoneCoordinate[];
+47
View File
@@ -0,0 +1,47 @@
/**
* Canonicalize a provider-neutral GeoJSON linear ring before it reaches a
* Cesium geometry worker. GeoJSON closes a ring by repeating its first
* coordinate, while GroundPolylineGeometry with `loop: true` expects an open
* sequence. Some source snapshots contain both adjacent duplicates and more
* than one closing coordinate; forwarding either shape can create a
* zero-length segment and stop Cesium's render loop.
*
* Invalid coordinates invalidate the complete ring. Silently retaining a
* partial polygon would display a boundary the provider never published.
*
* @param {Array<[number, number]>} ring
* @returns {Array<[number, number]>}
*/
export function normalizeHGeoZoneRing(ring) {
if (!Array.isArray(ring)) return [];
/** @type {Array<[number, number]>} */
const normalized = [];
for (const coordinate of ring) {
if (
!Array.isArray(coordinate)
|| coordinate.length < 2
|| !Number.isFinite(coordinate[0])
|| !Number.isFinite(coordinate[1])
|| coordinate[0] < -180
|| coordinate[0] > 180
|| coordinate[1] < -90
|| coordinate[1] > 90
) return [];
const next = /** @type {[number, number]} */ ([coordinate[0], coordinate[1]]);
if (!sameCoordinate(normalized.at(-1), next)) normalized.push(next);
}
while (normalized.length > 1 && sameCoordinate(normalized[0], normalized.at(-1))) {
normalized.pop();
}
const distinct = new Set(normalized.map(([longitude, latitude]) => `${longitude}:${latitude}`));
return distinct.size >= 3 ? normalized : [];
}
/**
* @param {[number, number] | undefined} left
* @param {[number, number] | undefined} right
*/
function sameCoordinate(left, right) {
return Boolean(left && right && left[0] === right[0] && left[1] === right[1]);
}