Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/polygon/CesiumPolygonOverlayRenderer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Entity> {
constructor(readonly holder: CesiumMapViewHolder) {}
Expand All @@ -27,10 +29,15 @@ export class CesiumPolygonOverlayRenderer implements PolygonOverlayRenderer<Enti
// one. Densify each ring ourselves — great-circle interpolation when
// geodesic, linear lon/lat interpolation (Google Maps' straight-line
// semantics) otherwise — so the two modes produce their intended shapes.
// Each ring includes its closing edge (last vertex -> 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
Expand Down Expand Up @@ -61,7 +68,11 @@ export class CesiumPolygonOverlayRenderer implements PolygonOverlayRenderer<Enti
width: state.strokeWidth,
material: toCesiumColor(state.strokeColor),
clampToGround: true,
arcType: ArcType.GEODESIC,
// Same arc type as the fill: between the densified vertices both
// fill and outline take the rhumb (lon/lat) segment, so they trace
// one line. With GEODESIC the outline bowed away from the fill on
// every segment, most visibly on long undensified ones.
arcType: ArcType.RHUMB,
zIndex: POLYGON_Z_BASE + state.zIndex,
})
: undefined;
Expand Down
75 changes: 75 additions & 0 deletions test/PolygonClosingEdge.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { after, test } from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { build } from 'tsup';
import { createGeoPoint, createPolygonState } from '@mapconductor/js-sdk-core';

// The fill and the outline of a polygon must trace the same line, closing edge
// (last vertex -> 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}`);
});
13 changes: 13 additions & 0 deletions test/stubs/cesium.mjs
Original file line number Diff line number Diff line change
@@ -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); } }