diff --git a/src/polygon/CesiumPolygonOverlayRenderer.ts b/src/polygon/CesiumPolygonOverlayRenderer.ts index 7a01afd..8ef4446 100644 --- a/src/polygon/CesiumPolygonOverlayRenderer.ts +++ b/src/polygon/CesiumPolygonOverlayRenderer.ts @@ -1,5 +1,5 @@ import { ArcType, Cartesian3, Entity, PolygonGraphics, PolygonHierarchy, PolylineGraphics } from 'cesium'; -import { WGS84Geodesic, Planar, resolveHoles, type GeoPoint, type PolygonAddParams, type PolygonChangeParams, type PolygonEntity, type PolygonOverlayRenderer, type PolygonState } from '@mapconductor/js-sdk-core'; +import { densifyPolygonRing, resolveHoles, type GeoPoint, type PolygonAddParams, type PolygonChangeParams, type PolygonEntity, type PolygonOverlayRenderer, type PolygonState } from '@mapconductor/js-sdk-core'; import { CesiumMapViewHolder } from '../CesiumMapViewHolder'; import { toCesiumColor } from '../color'; import { pointsToDegrees } from '../helpers'; @@ -9,6 +9,8 @@ import { POLYGON_Z_BASE } from '../zOrder'; // Same segment length react-for-arcgis uses for polygons: coarser than // polylines so world-scale rings don't densify into huge geometries. const GEODESIC_MAX_SEGMENT_LENGTH_METERS = 100_000; +// Planar.createInterpolatePoints' default, the same as polylines use. +const LINEAR_MAX_SEGMENT_LENGTH_METERS = 10_000; export class CesiumPolygonOverlayRenderer implements PolygonOverlayRenderer { constructor(readonly holder: CesiumMapViewHolder) {} @@ -27,10 +29,15 @@ export class CesiumPolygonOverlayRenderer implements PolygonOverlayRenderer first) in both + // modes: interpolating only between neighbours leaves that edge one long + // rhumb segment in the fill while the outline curved along it. const densify = (points: GeoPoint[]): GeoPoint[] => - state.geodesic - ? WGS84Geodesic.createInterpolatePoints(points, GEODESIC_MAX_SEGMENT_LENGTH_METERS) - : Planar.createInterpolatePoints(points); + densifyPolygonRing( + points, + state.geodesic, + state.geodesic ? GEODESIC_MAX_SEGMENT_LENGTH_METERS : LINEAR_MAX_SEGMENT_LENGTH_METERS, + ); // No height: the polygon renders as ground geometry, which is what makes // Cesium honour zIndex ordering against other overlays (polylines sit in @@ -61,7 +68,11 @@ export class CesiumPolygonOverlayRenderer implements PolygonOverlayRenderer first) included, in both geodesic modes. Cesium is replaced +// by a stub that records the graphics the renderer builds. +mkdirSync(new URL('../build/', import.meta.url), { recursive: true }); +const outDir = mkdtempSync(new URL('../build/polygon-closing-edge-tests-', import.meta.url)); +after(() => rmSync(outDir, { recursive: true, force: true })); +const cesiumStub = fileURLToPath(new URL('./stubs/cesium.mjs', import.meta.url)); +await build({ + entry: { renderer: 'src/polygon/CesiumPolygonOverlayRenderer.ts' }, + outDir, format: ['esm'], platform: 'node', splitting: false, + dts: false, sourcemap: false, config: false, silent: true, + external: ['@mapconductor/js-sdk-core'], + noExternal: ['cesium'], + esbuildOptions(options) { options.alias = { cesium: cesiumStub }; }, +}); +const { CesiumPolygonOverlayRenderer } = await import(pathToFileURL(resolve(outDir, 'renderer.mjs'))); + +const p = (latitude, longitude) => createGeoPoint({ latitude, longitude }); +const BOSTON = p(42.36, -71.06); +const LONDON = p(51.5, -0.12); +const ICELAND = p(64.1, -21.9); +const TRIANGLE = [BOSTON, LONDON, ICELAND]; + +async function render(geodesic) { + const entities = []; + const holder = { + isDestroyed: () => false, + map: { entities: { add: (entity) => (entities.push(entity), entity) }, scene: { requestRender() {} } }, + }; + const state = createPolygonState({ + id: 'tri', points: TRIANGLE, geodesic, strokeWidth: 2, strokeColor: '#000', fillColor: '#f00', + }); + await new CesiumPolygonOverlayRenderer(holder).onAdd([{ state }]); + return entities[0]; +} + +/** [lon, lat, lon, lat, ...] -> [[lon, lat], ...] */ +const pairs = (degrees) => degrees.reduce((acc, v, i) => (i % 2 ? acc[acc.length - 1].push(v) : acc.push([v]), acc), []); + +for (const geodesic of [true, false]) { + test(`geodesic=${geodesic}: fill and outline use the same vertices and arc type`, async () => { + const entity = await render(geodesic); + const fill = pairs(entity.polygon.hierarchy.positions); + const outline = pairs(entity.polyline.positions); + // The outline is the fill ring closed with its first vertex. + assert.deepEqual(outline.slice(0, -1), fill); + assert.deepEqual(outline[outline.length - 1], fill[0]); + assert.equal(entity.polyline.arcType, entity.polygon.arcType); + }); +} + +test('geodesic=false: the closing edge is densified along the lat/lng line', async () => { + const fill = pairs((await render(false)).polygon.hierarchy.positions); + // Halfway along Iceland -> Boston the linear edge passes 53.23N at 46.48W. + const midLng = (ICELAND.longitude + BOSTON.longitude) / 2; + const midLat = (ICELAND.latitude + BOSTON.latitude) / 2; + const nearest = Math.min(...fill.map(([lng, lat]) => Math.hypot(lat - midLat, lng - midLng))); + assert.ok(nearest < 0.1, `nearest ${nearest}`); +}); + +test('geodesic=true: the closing edge is densified along the great circle', async () => { + const fill = pairs((await render(true)).polygon.hierarchy.positions); + // Iceland -> Boston passes 55.6N at 53.2W (the undensified edge stays about 4 degrees away). + // Geodesic rings are densified every 100 km, so allow half a segment. + const nearest = Math.min(...fill.map(([lng, lat]) => Math.hypot(lat - 55.63, lng + 53.17))); + assert.ok(nearest < 1, `nearest ${nearest}`); +}); diff --git a/test/stubs/cesium.mjs b/test/stubs/cesium.mjs new file mode 100644 index 0000000..d843cc5 --- /dev/null +++ b/test/stubs/cesium.mjs @@ -0,0 +1,13 @@ +// Minimal stand-in for 'cesium' that records what the polygon renderer builds. +export const ArcType = { NONE: 'NONE', GEODESIC: 'GEODESIC', RHUMB: 'RHUMB' }; +export class Color { static WHITE = new Color(); static fromCssColorString() { return new Color(); } } +export const Cartesian3 = { fromDegreesArray: (degrees) => [...degrees] }; +export class Cartesian2 {} +export class Cartographic {} +export const SceneTransforms = {}; +const CesiumMath = {}; +export { CesiumMath as Math }; +export class Entity { constructor(options) { Object.assign(this, options); } } +export class PolygonHierarchy { constructor(positions, holes = []) { this.positions = positions; this.holes = holes; } } +export class PolygonGraphics { constructor(options) { Object.assign(this, options); } } +export class PolylineGraphics { constructor(options) { Object.assign(this, options); } }