fix(foundry): isolate HGeoZone projection faults
This commit is contained in:
parent
309cddef73
commit
ca26702047
|
|
@ -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[];
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue