diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 1486573..351f4f3 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -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>; + errors: Partial>; }; 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>) { - 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(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, + faultedGeometryKeys: Set, +) { + 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) { 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, ) { 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, ) { 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 void) | null>(null); const runtimeDataSourcesRef = useRef(new Map()); const hGeoZoneLayersRef = useRef(new Map()); + const faultedHGeoZoneGeometryKeysRef = useRef(new Set()); const presentationRef = useRef(presentation); const runtimeBindingsRef = useRef(runtimeBindings); const presentationProfilesRef = useRef(presentationProfiles); @@ -1354,6 +1396,7 @@ export const CesiumMapRenderer = forwardRef rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current); @@ -1487,9 +1522,22 @@ export const CesiumMapRenderer = forwardRef { @@ -1501,6 +1549,18 @@ export const CesiumMapRenderer = forwardRef} 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]); +} diff --git a/package.json b/package.json index f185aee..576eab3 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/hgeozone-projection.test.mjs b/scripts/hgeozone-projection.test.mjs new file mode 100644 index 0000000..78f9c39 --- /dev/null +++ b/scripts/hgeozone-projection.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); +});