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

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]);

View File

@ -0,0 +1,5 @@
export type HGeoZoneCoordinate = [number, number];
export declare function normalizeHGeoZoneRing(
ring: HGeoZoneCoordinate[],
): HGeoZoneCoordinate[];

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]);
}

View File

@ -11,7 +11,7 @@
"scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs",
@ -22,6 +22,7 @@
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
},

View File

@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ApproximateTerrainHeights from "@cesium/engine/Source/Core/ApproximateTerrainHeights.js";
import Cartesian3 from "@cesium/engine/Source/Core/Cartesian3.js";
import GroundPolylineGeometry from "@cesium/engine/Source/Core/GroundPolylineGeometry.js";
import { normalizeHGeoZoneRing } from "../apps/catalog/src/hGeoZoneProjection.mjs";
test("normalizes GeoJSON closure and adjacent duplicates without changing the polygon", () => {
assert.deepEqual(normalizeHGeoZoneRing([
[37, 55],
[37, 55],
[38, 55],
[38, 56],
[37, 55],
[37, 55],
]), [
[37, 55],
[38, 55],
[38, 56],
]);
});
test("rejects an invalid or degenerate ring instead of publishing partial geometry", () => {
assert.deepEqual(normalizeHGeoZoneRing([[37, 55], [38, 55], [37, 55]]), []);
assert.deepEqual(normalizeHGeoZoneRing([[37, 55], [38, Number.NaN], [38, 56], [37, 55]]), []);
assert.deepEqual(normalizeHGeoZoneRing([[37, 55], [181, 55], [38, 56], [37, 55]]), []);
});
test("normalized double-closed ring is accepted by Cesium GroundPolylineGeometry", async () => {
ApproximateTerrainHeights._terrainHeights = JSON.parse(await readFile(
new URL("../node_modules/@cesium/engine/Source/Assets/approximateTerrainHeights.json", import.meta.url),
"utf8",
));
const positions = normalizeHGeoZoneRing([
[37.55, 55.75],
[37.56, 55.75],
[37.56, 55.76],
[37.55, 55.75],
[37.55, 55.75],
]).map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude));
const geometry = GroundPolylineGeometry.createGeometry(new GroundPolylineGeometry({
positions,
width: 1.5,
loop: true,
}));
assert.ok(geometry);
});