mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'german/refactor-e9-import-plan' into german/navara-refactor
This commit is contained in:
commit
49b180f1fd
32 files changed
+103263
-458
No files matched your search
@@ -6703,8 +6703,9 @@ request viewbox and continues searching without that geographic preference.
|
||||
### Import Your Own Files (October 2026)
|
||||
|
||||
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- **Planning and limits.** `geoImportPlan.js` turns plain records and supplied image dimensions into drawing plans and undrawn counts without a DOM or engine. `geoImportDisplay.js` loads images, applies the plan through the globe contract and owns the drawings; `geoImportLimits.js` holds the shared reader, image, drawing and scheduling budgets.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses; on a tile surface a pin outside the window keeps its last tile height, which the window test also uses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set.
|
||||
- **Drawing limits.** Raised polygons draw their fills (meshes triangulated in the plane tangent to the Earth, as Cesium's perPositionHeight) and outlines at their vertices' heights; a raised polygon of more than 128 rings is not drawn, and one of more than 4,096 vertices draws only its outlines (both counted as undrawn). Ground overlays take their colour's tint and alpha and stack by KML `drawOrder`, then document order (a file imported later draws over); a rotated one is turned on a canvas over the box around it; a file's tinted and turned canvases share a budget of 16 million pixels and equal ones are drawn once; an absolute image overlay is a textured mesh fixed at its altitude, as Cesium's rectangle at a height (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised plain-colour overlay are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw. A hole of fewer than three distinct corners is dropped; a ground polygon the ground contract still refuses is counted as undrawn.
|
||||
- **Drawing limits.** Raised polygons draw their fills (meshes triangulated in the plane tangent to the Earth, as Cesium's perPositionHeight) and outlines at their vertices' heights; a raised polygon of more than 128 rings is not drawn, and one of more than 32,768 vertices draws only its outlines (both counted as undrawn). Repeated closing vertices do not consume the fill budget. Ground overlays take their colour's tint and alpha and stack by KML `drawOrder`, then document order (a file imported later draws over); a rotated one is turned on a canvas over the box around it; a file's tinted and turned canvases share a budget of 16 million pixels and equal ones are drawn once; an absolute image or plain-colour overlay is a mesh fixed at its altitude, including negative heights, as Cesium's rectangle at a height (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad` and a rotated overlay whose box passes a pole are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw. A hole of fewer than three distinct corners is dropped; a ground polygon the ground contract still refuses is counted as undrawn.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rectangular overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||
- **Input handling.** Coordinates and GeoJSON geometry types are checked before drawing allocation. Only short plain names reach the record model; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
|
||||
@@ -6712,6 +6713,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 drawable records (including multipart children); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
|
||||
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with the scene engine, navigation, ground services and map-source subscription. Detach destroys drawings and revokes packed-image URLs; reattach reparses the retained file bytes and restores visibility. Destroy aborts pending work, unsubscribes, removes drawings, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
|
||||
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, effective style equivalence against a native Cesium control, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
|
||||
- **Reference corpus.** `scripts/fixtures/geo-import/` holds 13 KML/KMZ/GeoJSON fixtures covering heights, rotations, poles, the dateline, holes, ordering, styles, packed images and a 20,000-vertex polygon. `node scripts/qa-geo-import-reference.mjs` compares planned geometry and styles with Cesium 1.138.0 DataSources in Node; `--record` recreates the reference offline with canvas pins stubbed and KMZ images resolved locally. Pixel composition and ordering between primitive families remain browser checks.
|
||||
|
||||
### Recent Imagery (September 2026)
|
||||
|
||||
|
||||
@@ -350,12 +350,13 @@ Raised polygons (KML absolute or relative) draw their fills as meshes and
|
||||
their outlines as lines at the vertices' heights. A ground overlay's colour
|
||||
tints its image and its alpha fades it; a rotated overlay's image is turned
|
||||
on a canvas over the box around it, as Cesium turns its rectangle in degrees,
|
||||
and an absolute overlay is raised to its altitude (Cesium raises only
|
||||
absolute ones). The shared display has known limits against Cesium: wall
|
||||
fills and extruded prisms are omitted (their top geometry remains), and
|
||||
`gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised
|
||||
plain-colour overlay are omitted. These are counted as undrawn. Ground edges
|
||||
use great circles rather than rhumb lines.
|
||||
and an absolute image or plain-colour overlay is a mesh at its altitude,
|
||||
including negative heights (Cesium raises only absolute ones).
|
||||
The shared display has known limits against Cesium: wall fills and extruded
|
||||
prisms are omitted (their top geometry remains), and
|
||||
`gx:LatLonQuad` and a rotated overlay whose box passes a pole are omitted.
|
||||
These are counted as undrawn. Ground edges use great circles rather than
|
||||
rhumb lines.
|
||||
|
||||
## Input ownership
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Import reference corpus
|
||||
|
||||
`reference.json` records Cesium 1.138.0 DataSources in Node with a DOM shim,
|
||||
canvas pins stubbed and KMZ images resolved locally. It is independent of
|
||||
`planImport` and checks entity geometry and styles, without rendering pixels.
|
||||
|
||||
Record and compare offline from the repository root; no server or browser is
|
||||
needed. Recording uses the installed Cesium version, saved in the reference:
|
||||
|
||||
```sh
|
||||
node scripts/qa-geo-import-reference.mjs --record
|
||||
node scripts/qa-geo-import-reference.mjs
|
||||
```
|
||||
|
||||
Recording uses Cesium's `KmlDataSource` and `GeoJsonDataSource` after the import's
|
||||
sanitization and GeoJSON grounding policy. Files are read from this directory;
|
||||
packed images use local blob URLs. Comparison uses 0.01 metres for heights,
|
||||
1e-7 degrees for coordinates and one 8-bit channel (1/255) for colours.
|
||||
Repeated ring endpoints and ignored short holes are normalized; input winding
|
||||
is retained. Image overlay order is compared separately, including ties in
|
||||
document order. Pixel composition and ordering between primitive families
|
||||
still need browser acceptance.
|
||||
|
||||
| File | Cases |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `absolute.kml` | Absolute pins, lines and polygons; positive and negative image heights; raised rotated colour overlay |
|
||||
| `relative.kml` | Relative pins, lines, polygons and image overlay |
|
||||
| `clamped.kml` | Clamped heights and tessellation |
|
||||
| `rotations.kml` | Zero, positive, negative and full turns; a tinted 360-degree span |
|
||||
| `pole.kml` | Rotation near and past a pole; polar polygon |
|
||||
| `antimeridian.kml` | Dateline polygon and rotated image |
|
||||
| `holes.kml` | Ring closure and opposite windings with holes |
|
||||
| `short-holes.kml` | Short and duplicate-corner holes |
|
||||
| `draw-order.kml` | Negative, positive and equal image orders; plain colour |
|
||||
| `styles.kml` | KML colours, line width, icon scale, heading and hot spot |
|
||||
| `geometries.geojson` | Grounded point, line and polygon |
|
||||
| `large-polygon.kml` | A raised polygon with 20,000 vertices |
|
||||
| `packed.kmz` | An icon and a tinted, rotated overlay using a packed PNG |
|
||||
|
||||
The corpus checks negative absolute image heights and raised plain-colour
|
||||
overlays against Cesium. Short holes are skipped on direct record input without
|
||||
rejecting the whole ground polygon. Raised fills have a 32,768-vertex ceiling
|
||||
and raised polygons a 128-ring ceiling in `src/data/geoImportLimits.js`;
|
||||
a repeated closing vertex does not consume triangulation budget. The large
|
||||
polygon fixture exercises a 20,000-vertex fill within that budget.
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>absolute-pin</name><Point><altitudeMode>absolute</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
|
||||
<Placemark><name>absolute-line-0</name><LineString><altitudeMode>absolute</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>absolute-line-1</name><LineString><altitudeMode>absolute</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>absolute-polygon</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<GroundOverlay><name>absolute-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>negative-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>-100</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>raised-color</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>200</altitude><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>30</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>crossing-image</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>179</west><south>-2</south><east>-179</east><north>2</north><rotation>30</rotation></LatLonBox></GroundOverlay>
|
||||
<Placemark><name>crossing-ring</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>179,-1,100 -179,-1,100 -179,1,100 179,1,100</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>clamped-pin</name><Point><altitudeMode>clampToGround</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
|
||||
<Placemark><name>clamped-line-0</name><LineString><altitudeMode>clampToGround</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>clamped-line-1</name><LineString><altitudeMode>clampToGround</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>clamped-polygon</name><Polygon><altitudeMode>clampToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<GroundOverlay><name>clamped-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>clampToGround</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>top</name><drawOrder>9</drawOrder><color>804020ff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>first</name><drawOrder>-2</drawOrder><color>ff00ff00</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>equal-a</name><drawOrder>3</drawOrder><color>ff0000ff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>equal-b</name><drawOrder>3</drawOrder><color>ffff0000</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>plain-color</name><drawOrder>0</drawOrder><color>804020ff</color><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>25</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"json-point"},"geometry":{"type":"Point","coordinates":[10,20,99]}},{"type":"Feature","properties":{"name":"json-line"},"geometry":{"type":"LineString","coordinates":[[10,20,4],[11,21,8]]}},{"type":"Feature","properties":{"name":"json-polygon"},"geometry":{"type":"Polygon","coordinates":[[[0,0,100],[2,0,100],[2,2,100],[0,2,100],[0,0,100]],[[0.5,0.5,100],[0.5,1.5,100],[1.5,1.5,100],[1.5,0.5,100],[0.5,0.5,100]]]}}]}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>open</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>closed</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>reversed</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 0,2,100 2,2,100 2,0,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 1.5,0.5,100 1.5,1.5,100 0.5,1.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>ground-holes</name><Polygon><altitudeMode>clampToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>large</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>10.1,20.0,500 10.099999995,20.000031416,500 10.09999998,20.000062832,500 10.099999956,20.000094248,500 10.099999921,20.000125664,500 10.099999877,20.00015708,500 10.099999822,20.000188495,500 10.099999758,20.000219911,500 10.099999684,20.000251327,500 10.0999996,20.000282743,500 10.099999507,20.000314159,500 10.099999403,20.000345575,500 10.099999289,20.00037699,500 10.099999166,20.000408406,500 10.099999033,20.000439822,500 10.09999889,20.000471237,500 10.099998737,20.000502653,500 10.099998574,20.000534068,500 10.099998401,20.000565484,500 10.099998219,20.000596899,500 10.099998026,20.000628314,500 10.099997824,20.00065973,500 10.099997612,20.000691145,500 10.09999739,20.00072256,500 10.099997158,20.000753975,500 10.099996916,20.00078539,500 10.099996664,20.000816805,500 10.099996403,20.00084822,500 10.099996131,20.000879635,500 10.09999585,20.000911049,500 10.099995559,20.000942464,500 10.099995258,20.000973878,500 10.099994947,20.001005293,500 10.099994626,20.001036707,500 10.099994295,20.001068121,500 10.099993955,20.001099535,500 10.099993605,20.001130949,500 10.099993244,20.001162363,500 10.099992874,20.001193777,500 10.099992494,20.00122519,500 10.099992104,20.001256604,500 10.099991705,20.001288017,500 10.099991295,20.001319431,500 10.099990876,20.001350844,500 10.099990446,20.001382257,500 10.099990007,20.00141367,500 10.099989558,20.001445082,500 10.099989099,20.001476495,500 10.09998863,20.001507907,500 10.099988152,20.00153932,500 10.099987663,20.001570732,500 10.099987165,20.001602144,500 10.099986657,20.001633556,500 10.099986138,20.001664967,500 10.09998561,20.001696379,500 10.099985073,20.00172779,500 10.099984525,20.001759201,500 10.099983967,20.001790612,500 10.0999834,20.001822023,500 10.099982822,20.001853434,500 10.099982235,20.001884844,500 10.099981638,20.001916254,500 10.099981031,20.001947664,500 10.099980414,20.001979074,500 10.099979788,20.002010484,500 10.099979151,20.002041893,500 10.099978505,20.002073303,500 10.099977848,20.002104712,500 10.099977182,20.002136121,500 10.099976506,20.002167529,500 10.09997582,20.002198938,500 10.099975125,20.002230346,500 10.099974419,20.002261754,500 10.099973704,20.002293162,500 10.099972978,20.002324569,500 10.099972243,20.002355976,500 10.099971498,20.002387384,500 10.099970743,20.00241879,500 10.099969978,20.002450197,500 10.099969203,20.002481603,500 10.099968419,20.00251301,500 10.099967625,20.002544415,500 10.09996682,20.002575821,500 10.099966006,20.002607226,500 10.099965182,20.002638632,500 10.099964348,20.002670036,500 10.099963504,20.002701441,500 10.099962651,20.002732845,500 10.099961787,20.002764249,500 10.099960914,20.002795653,500 10.099960031,20.002827057,500 10.099959138,20.00285846,500 10.099958235,20.002889863,500 10.099957322,20.002921266,500 10.099956399,20.002952668,500 10.099955467,20.00298407,500 10.099954524,20.003015472,500 10.099953572,20.003046873,500 10.09995261,20.003078274,500 10.099951638,20.003109675,500 10.099950656,20.003141076,500 10.099949664,20.003172476,500 10.099948663,20.003203876,500 10.099947651,20.003235276,500 10.09994663,20.003266675,500 10.099945599,20.003298074,500 10.099944558,20.003329473,500 10.099943507,20.003360871,500 10.099942446,20.003392269,500 10.099941375,20.003423667,500 10.099940295,20.003455064,500 10.099939204,20.003486461,500 10.099938104,20.003517858,500 10.099936994,20.003549254,500 10.099935874,20.00358065,500 10.099934744,20.003612046,500 10.099933605,20.003643441,500 10.099932455,20.003674836,500 10.099931296,20.00370623,500 10.099930126,20.003737624,500 10.099928947,20.003769018,500 10.099927758,20.003800412,500 10.099926559,20.003831805,500 10.099925351,20.003863197,500 10.099924132,20.00389459,500 10.099922904,20.003925982,500 10.099921665,20.003957373,500 10.099920417,20.003988764,500 10.099919159,20.004020155,500 10.099917891,20.004051545,500 10.099916613,20.004082935,500 10.099915326,20.004114325,500 10.099914028,20.004145714,500 10.099912721,20.004177103,500 10.099911404,20.004208491,500 10.099910077,20.004239879,500 10.09990874,20.004271266,500 10.099907393,20.004302653,500 10.099906036,20.00433404,500 10.09990467,20.004365426,500 10.099903293,20.004396812,500 10.099901907,20.004428197,500 10.099900511,20.004459582,500 10.099899105,20.004490967,500 10.099897689,20.004522351,500 10.099896264,20.004553734,500 10.099894828,20.004585117,500 10.099893383,20.0046165,500 10.099891928,20.004647882,500 10.099890462,20.004679264,500 10.099888987,20.004710645,500 10.099887503,20.004742026,500 10.099886008,20.004773406,500 10.099884503,20.004804786,500 10.099882989,20.004836166,500 10.099881465,20.004867544,500 10.099879931,20.004898923,500 10.099878387,20.004930301,500 10.099876833,20.004961678,500 10.099875269,20.004993055,500 10.099873696,20.005024432,500 10.099872112,20.005055808,500 10.099870519,20.005087183,500 10.099868916,20.005118558,500 10.099867303,2Line truncated
|
||||
|
||||
</Document></kml>
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>near-pole</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-2</west><south>84</south><east>2</east><north>86</north><rotation>30</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>past-pole</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-10</west><south>86</south><east>10</east><north>89</north><rotation>45</rotation></LatLonBox></GroundOverlay>
|
||||
<Placemark><name>polar-ring</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>10,88,200 14,88,200 14,88.2,200 10,88.2,200</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>relative-pin</name><Point><altitudeMode>relativeToGround</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
|
||||
<Placemark><name>relative-line-0</name><LineString><altitudeMode>relativeToGround</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>relative-line-1</name><LineString><altitudeMode>relativeToGround</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>relative-polygon</name><Polygon><altitudeMode>relativeToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<GroundOverlay><name>relative-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>relativeToGround</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>rotation-0</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation-45</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>45</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation--30</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>-30</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation-360</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>360</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation--360</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>-360</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>all-longitudes</name><drawOrder>0</drawOrder><color>ff80ffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-180</west><south>-1</south><east>180</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>two-point-hole</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.4,0.4,100 0.8,0.4,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>repeated-hole</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.4,0.4,100 0.8,0.4,100 0.4,0.4,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Style id="shared"><LineStyle><color>804020ff</color><width>5</width></LineStyle><PolyStyle><color>7f00ffff</color><outline>1</outline></PolyStyle><IconStyle><color>cc804020</color><scale>1.5</scale><heading>45</heading><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><hotSpot x="0.25" y="0.75" xunits="fraction" yunits="fraction"/></IconStyle></Style><StyleMap id="mapped"><Pair><key>normal</key><styleUrl>#shared</styleUrl></Pair></StyleMap><Placemark><name>styled-polygon</name><styleUrl>#mapped</styleUrl><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>styled-pin</name><styleUrl>#mapped</styleUrl><Point><coordinates>10,20</coordinates></Point></Placemark><Placemark><name>styled-line</name><styleUrl>#shared</styleUrl><LineString><coordinates>10,20 11,21</coordinates></LineString></Placemark>
|
||||
</Document></kml>
|
||||
@@ -3590,7 +3590,9 @@
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js",
|
||||
"src/globe/cesium/orbital.js",
|
||||
"src/globe/cesium/worldTime.js"
|
||||
"src/globe/cesium/worldTime.js",
|
||||
"src/data/geoImportLimits.js",
|
||||
"src/data/geoImportPlan.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4302,7 +4304,8 @@
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
"src/data/planespottersPhoto.js",
|
||||
"src/data/geoImportLimits.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -5254,7 +5257,8 @@
|
||||
"src/data/geoImportImages.js",
|
||||
"src/data/geoImportRecords.js",
|
||||
"src/data/geoImportKml.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/geoImportLimits.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
#!/usr/bin/env node
|
||||
// Record offline: node scripts/qa-geo-import-reference.mjs --record
|
||||
// Compare offline: node scripts/qa-geo-import-reference.mjs
|
||||
// Positions: 1 cm height and 1e-7 degrees; colours: one 8-bit channel.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { planImport } from '../src/data/geoImportPlan.js';
|
||||
import * as geo from '../src/geo/index.js';
|
||||
import { iconBox } from '../src/data/geoImportPins.js';
|
||||
|
||||
const directory = fileURLToPath(
|
||||
new URL('./fixtures/geo-import/', import.meta.url),
|
||||
);
|
||||
const referencePath = path.join(directory, 'reference.json');
|
||||
const files = (await fs.readdir(directory))
|
||||
.filter((name) => /\.(kml|kmz|geojson)$/.test(name))
|
||||
.sort();
|
||||
const tolerances = { degrees: 1e-7, metres: 0.01, color: 1 / 255 };
|
||||
const openRing = (ring) => {
|
||||
const last = ring.at(-1);
|
||||
return ring.length > 1 &&
|
||||
Math.abs(ring[0][0] - last[0]) < 1e-7 &&
|
||||
Math.abs(ring[0][1] - last[1]) < 1e-7
|
||||
? ring.slice(0, -1)
|
||||
: ring;
|
||||
};
|
||||
const worldPosition = (p) => {
|
||||
const c = geo.cartographic.fromCartesian(p);
|
||||
return [
|
||||
geo.math.toDegrees(c.longitude),
|
||||
geo.math.toDegrees(c.latitude),
|
||||
c.height,
|
||||
];
|
||||
};
|
||||
function meshPositions(mesh) {
|
||||
const result = [];
|
||||
for (let i = 0; i < mesh.positions.length; i += 3)
|
||||
result.push(
|
||||
worldPosition({
|
||||
x: mesh.positions[i],
|
||||
y: mesh.positions[i + 1],
|
||||
z: mesh.positions[i + 2],
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
function area(positions, indices) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < indices.length; i += 3) {
|
||||
const [a, b, c] = Array.from(indices.slice(i, i + 3), (n) =>
|
||||
positions.slice(n * 3, n * 3 + 3),
|
||||
);
|
||||
const u = b.map((v, j) => v - a[j]),
|
||||
v = c.map((v, j) => v - a[j]);
|
||||
total +=
|
||||
Math.hypot(
|
||||
u[1] * v[2] - u[2] * v[1],
|
||||
u[2] * v[0] - u[0] * v[2],
|
||||
u[0] * v[1] - u[1] * v[0],
|
||||
) / 2;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function record() {
|
||||
const { Window } = await import('happy-dom');
|
||||
const dom = new Window({ url: 'http://localhost/' });
|
||||
globalThis.document = dom.document;
|
||||
globalThis.DOMParser = dom.DOMParser;
|
||||
globalThis.XMLSerializer = dom.XMLSerializer;
|
||||
// happy-dom's XML namespace query misses KML elements Cesium reads.
|
||||
function byNamespace(namespace, localName) {
|
||||
return [...this.getElementsByTagName('*')].filter(
|
||||
(el) =>
|
||||
(localName === '*' || el.localName === localName) &&
|
||||
(namespace === '*' || el.namespaceURI === namespace),
|
||||
);
|
||||
}
|
||||
const probe = new DOMParser().parseFromString(
|
||||
'<a><b/></a>',
|
||||
'application/xml',
|
||||
);
|
||||
for (const node of [probe, probe.documentElement])
|
||||
for (let p = Object.getPrototypeOf(node); p; p = Object.getPrototypeOf(p))
|
||||
if (Object.hasOwn(p, 'getElementsByTagNameNS')) {
|
||||
p.getElementsByTagNameNS = byNamespace;
|
||||
break;
|
||||
}
|
||||
|
||||
// Cesium captures the document and XML parser when its modules load.
|
||||
const C = await import('cesium');
|
||||
const reader = await import('../src/data/geoFileImport.js');
|
||||
const fromColor = C.PinBuilder.prototype.fromColor;
|
||||
// Only entity properties are recorded; no pin pixels are rendered.
|
||||
C.PinBuilder.prototype.fromColor = (_color, size) => ({
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
const result = {
|
||||
version: 1,
|
||||
recorder: `Cesium ${C.VERSION} DataSources in Node; canvas pins stubbed, KMZ images resolved locally. Re-record with node scripts/qa-geo-import-reference.mjs --record.`,
|
||||
tolerances,
|
||||
cesiumVersion: C.VERSION,
|
||||
files: {},
|
||||
};
|
||||
console.log(result.recorder);
|
||||
const now = C.JulianDate.fromIso8601('2020-01-01T12:00:00Z');
|
||||
const v = (property) => property?.getValue?.(now);
|
||||
const rgba = (color) =>
|
||||
color ? [color.red, color.green, color.blue, color.alpha] : [1, 1, 1, 1];
|
||||
const position = (p) => {
|
||||
const c = C.Cartographic.fromCartesian(p);
|
||||
return [
|
||||
C.Math.toDegrees(c.longitude),
|
||||
C.Math.toDegrees(c.latitude),
|
||||
c.height,
|
||||
];
|
||||
};
|
||||
const ring = (positions, raised) => {
|
||||
const out = positions
|
||||
.map(position)
|
||||
.map(([x, y, z]) => [x, y, raised ? z : 0]);
|
||||
if (
|
||||
out.length > 1 &&
|
||||
C.Cartesian3.equalsEpsilon(positions[0], positions.at(-1), 1e-12, 1e-7)
|
||||
)
|
||||
out.pop();
|
||||
return out;
|
||||
};
|
||||
try {
|
||||
for (const file of files) {
|
||||
const bytes = await fs.readFile(path.join(directory, file));
|
||||
const urls = [];
|
||||
try {
|
||||
let ds;
|
||||
if (file.endsWith('.geojson')) {
|
||||
const json = JSON.parse(bytes.toString('utf8'));
|
||||
// The import's GeoJSON policy keeps names and grounds every coordinate.
|
||||
const stack = [json];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (node.type === 'FeatureCollection') stack.push(...node.features);
|
||||
else if (node.type === 'Feature') {
|
||||
node.properties = { name: node.properties?.name };
|
||||
stack.push(node.geometry);
|
||||
} else if (node.type === 'GeometryCollection')
|
||||
stack.push(...node.geometries);
|
||||
else {
|
||||
const coords = [node.coordinates];
|
||||
while (coords.length) {
|
||||
const c = coords.pop();
|
||||
if (typeof c[0] === 'number') c.length = 2;
|
||||
else coords.push(...c);
|
||||
}
|
||||
}
|
||||
}
|
||||
const color = C.Color.fromCssColorString('#39d0ff');
|
||||
ds = await C.GeoJsonDataSource.load(json, {
|
||||
clampToGround: true,
|
||||
markerColor: color,
|
||||
markerSize: 48,
|
||||
stroke: color,
|
||||
strokeWidth: 3,
|
||||
fill: color.withAlpha(0.25),
|
||||
});
|
||||
} else {
|
||||
let text = reader.decodeXml(bytes),
|
||||
resolveLocal = () => null;
|
||||
if (file.endsWith('.kmz')) {
|
||||
const entries = await reader.readZipEntries(bytes);
|
||||
const main = reader.kmzMainDocument(entries);
|
||||
text = reader.decodeXml(entries.get(main));
|
||||
resolveLocal = reader.kmzImageResolver(entries, main, (url) =>
|
||||
urls.push(url),
|
||||
);
|
||||
}
|
||||
const doc = reader.parseXml(text, 'KML');
|
||||
// Keep the import's sanitization and tessellation policy.
|
||||
reader.sanitizeKmlDocument(doc, resolveLocal);
|
||||
reader.deduplicateKmlStyles(doc);
|
||||
ds = await C.KmlDataSource.load(doc, {
|
||||
clampToGround: true,
|
||||
});
|
||||
}
|
||||
const entities = [];
|
||||
for (const entity of ds.entities.values) {
|
||||
const name = entity.name;
|
||||
if (entity.billboard && entity.position) {
|
||||
const b = entity.billboard;
|
||||
const offset = v(b.pixelOffset),
|
||||
distance = v(b.scaleByDistance);
|
||||
const heightReference =
|
||||
v(b.heightReference) ?? C.HeightReference.NONE;
|
||||
const pos = position(v(entity.position));
|
||||
if (heightReference === C.HeightReference.CLAMP_TO_GROUND)
|
||||
pos[2] = 0;
|
||||
entities.push({
|
||||
kind: 'pin',
|
||||
name,
|
||||
positions: [pos],
|
||||
altitude:
|
||||
heightReference === C.HeightReference.RELATIVE_TO_GROUND
|
||||
? 'relative'
|
||||
: heightReference === C.HeightReference.CLAMP_TO_GROUND
|
||||
? 'ground'
|
||||
: 'absolute',
|
||||
color: rgba(v(b.color)),
|
||||
width: v(b.width) ?? 48,
|
||||
height: v(b.height) ?? 48,
|
||||
scale: v(b.scale) ?? 1,
|
||||
rotation: v(b.rotation) ?? 0,
|
||||
origin:
|
||||
v(b.verticalOrigin) === C.VerticalOrigin.BOTTOM
|
||||
? 'bottom'
|
||||
: 'center',
|
||||
offset: offset ? [offset.x, offset.y] : [0, 0],
|
||||
distance: distance
|
||||
? [
|
||||
distance.near,
|
||||
distance.nearValue,
|
||||
distance.far,
|
||||
distance.farValue,
|
||||
]
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (entity.polyline) {
|
||||
const line = entity.polyline;
|
||||
const ground = v(line.clampToGround) === true;
|
||||
entities.push({
|
||||
kind: 'line',
|
||||
name,
|
||||
ground,
|
||||
positions: v(line.positions)
|
||||
.map(position)
|
||||
.map(([x, y, z]) => [x, y, ground ? 0 : z]),
|
||||
color: rgba(v(line.material)?.color),
|
||||
width: v(line.width) ?? 1,
|
||||
});
|
||||
}
|
||||
if (entity.polygon) {
|
||||
const p = entity.polygon,
|
||||
hierarchy = v(p.hierarchy);
|
||||
const raised = v(p.perPositionHeight) === true;
|
||||
const rings = [
|
||||
hierarchy.positions,
|
||||
...hierarchy.holes.map((h) => h.positions),
|
||||
]
|
||||
.map((r) => ring(r, raised))
|
||||
.filter((r) => r.length >= 3);
|
||||
const fill = v(p.fill) !== false;
|
||||
entities.push({
|
||||
kind: 'polygon',
|
||||
name,
|
||||
raised,
|
||||
rings,
|
||||
color: fill ? rgba(v(p.material)?.color) : null,
|
||||
outline: raised && v(p.outline) ? rgba(v(p.outlineColor)) : null,
|
||||
});
|
||||
}
|
||||
if (entity.rectangle) {
|
||||
const r = entity.rectangle,
|
||||
rectangle = v(r.coordinates),
|
||||
rotation = v(r.rotation) ?? 0;
|
||||
let bounds,
|
||||
drawable = true;
|
||||
try {
|
||||
const b = C.RectangleGeometry.computeRectangle({
|
||||
rectangle,
|
||||
rotation,
|
||||
});
|
||||
bounds = [b.west, b.south, b.east, b.north].map(C.Math.toDegrees);
|
||||
} catch {
|
||||
drawable = false;
|
||||
bounds = null;
|
||||
}
|
||||
entities.push({
|
||||
kind: 'overlay',
|
||||
name,
|
||||
drawable,
|
||||
bounds,
|
||||
height: v(r.height) ?? 0,
|
||||
rotation: C.Math.toDegrees(rotation),
|
||||
drawOrder: v(r.zIndex) ?? 0,
|
||||
image: Boolean(r.material?.image),
|
||||
color: rgba(v(r.material)?.color),
|
||||
});
|
||||
}
|
||||
}
|
||||
result.files[file] = entities;
|
||||
console.log(`Recorded ${file}: ${entities.length} entities`);
|
||||
} finally {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
}
|
||||
}
|
||||
await fs.writeFile(referencePath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
} finally {
|
||||
C.PinBuilder.prototype.fromColor = fromColor;
|
||||
await dom.happyDOM.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function planned(file) {
|
||||
// Only the file reader needs XML; the planner remains independent of the DOM.
|
||||
const { Window } = await import('happy-dom');
|
||||
const dom = new Window();
|
||||
globalThis.DOMParser ??= dom.DOMParser;
|
||||
globalThis.XMLSerializer ??= dom.XMLSerializer;
|
||||
const { loadGeoFile } = await import('../src/data/geoFileImport.js');
|
||||
const { importImageSize } = await import('../src/data/geoImportImages.js');
|
||||
const bytes = await fs.readFile(path.join(directory, file));
|
||||
const loaded = await loadGeoFile(new File([bytes], file));
|
||||
try {
|
||||
const metadata = new Map();
|
||||
const pending = planImport(loaded.records);
|
||||
for (const url of pending.images) {
|
||||
const size = importImageSize(
|
||||
new Uint8Array(await (await fetch(url)).arrayBuffer()),
|
||||
);
|
||||
metadata.set(url, size ? { width: size[0], height: size[1] } : null);
|
||||
}
|
||||
const plan = planImport(loaded.records, undefined, { images: metadata });
|
||||
const entities = [];
|
||||
for (const look of plan.pins)
|
||||
for (const pin of look.draw ? look.pins : []) {
|
||||
const icon = look.icon,
|
||||
box = iconBox(icon, metadata.get(icon.image?.url));
|
||||
entities.push({
|
||||
kind: 'pin',
|
||||
name: pin.record.name,
|
||||
positions: [[pin.lon, pin.lat, pin.height ?? pin.offset]],
|
||||
altitude:
|
||||
pin.height !== null
|
||||
? 'absolute'
|
||||
: pin.offset
|
||||
? 'relative'
|
||||
: pin.record.altitude,
|
||||
color: pin.color,
|
||||
width: box.width / (icon.scale ?? 1),
|
||||
height: box.height / (icon.scale ?? 1),
|
||||
scale: icon.scale ?? 1,
|
||||
rotation: pin.rotation,
|
||||
origin: icon.origin ?? 'center',
|
||||
offset: icon.offsetPx ?? [0, 0],
|
||||
distance: icon.scaleByDistance ?? null,
|
||||
});
|
||||
}
|
||||
for (const line of loaded.records.lines) {
|
||||
const ground = plan.ground.find((entry) => entry.record === line);
|
||||
const raised = plan.lines.find((entry) => entry.record === line);
|
||||
if (!ground && !raised) continue;
|
||||
entities.push({
|
||||
kind: 'line',
|
||||
name: line.name,
|
||||
ground: Boolean(ground),
|
||||
positions: ground
|
||||
? ground.item.positions.map(([x, y]) => [x, y, 0])
|
||||
: raised.positions.map(worldPosition),
|
||||
color: (ground?.item ?? raised).color,
|
||||
width: (ground?.item ?? raised).widthPx,
|
||||
});
|
||||
}
|
||||
for (const polygon of loaded.records.polygons) {
|
||||
const ground = plan.ground.find((entry) => entry.record === polygon);
|
||||
const mesh = plan.meshes.find((entry) => entry.record === polygon);
|
||||
const outline = plan.lines.find((entry) => entry.record === polygon);
|
||||
let rings = [];
|
||||
if (ground)
|
||||
rings = ground.item.rings.map((r) =>
|
||||
openRing(r).map(([x, y]) => [x, y, 0]),
|
||||
);
|
||||
if (mesh) {
|
||||
const positions = meshPositions(mesh);
|
||||
let start = 0;
|
||||
rings = polygon.rings
|
||||
.map(openRing)
|
||||
.filter((r) => r.length >= 3)
|
||||
.map((r) => {
|
||||
const result = positions.slice(start, start + r.length);
|
||||
start += r.length;
|
||||
return result;
|
||||
});
|
||||
if (!(area(mesh.positions, mesh.indices) > 0))
|
||||
throw new Error(`${file}: empty raised fill`);
|
||||
}
|
||||
if (ground || mesh || outline)
|
||||
entities.push({
|
||||
kind: 'polygon',
|
||||
name: polygon.name,
|
||||
raised: Boolean(mesh || outline),
|
||||
rings,
|
||||
color: ground?.item.fill ?? mesh?.color ?? null,
|
||||
outline: outline?.color ?? null,
|
||||
});
|
||||
}
|
||||
for (const overlay of loaded.records.overlays) {
|
||||
const entry = [...plan.overlays, ...plan.meshes, ...plan.ground].find(
|
||||
(e) => e.record === overlay,
|
||||
);
|
||||
let bounds = null;
|
||||
if (entry?.rectangle) {
|
||||
const r = entry.rectangle;
|
||||
bounds = [r.west, r.south, r.east, r.north];
|
||||
} else if (entry?.item) {
|
||||
const points = entry.item.rings[0];
|
||||
bounds = [
|
||||
Math.min(...points.map((p) => p[0])),
|
||||
Math.min(...points.map((p) => p[1])),
|
||||
Math.max(...points.map((p) => p[0])),
|
||||
Math.max(...points.map((p) => p[1])),
|
||||
];
|
||||
}
|
||||
if (!bounds && entry?.positions) {
|
||||
const points = meshPositions(entry);
|
||||
bounds = [
|
||||
Math.min(...points.map((p) => p[0])),
|
||||
Math.min(...points.map((p) => p[1])),
|
||||
Math.max(...points.map((p) => p[0])),
|
||||
Math.max(...points.map((p) => p[1])),
|
||||
];
|
||||
}
|
||||
entities.push({
|
||||
kind: 'overlay',
|
||||
name: overlay.name,
|
||||
drawable: Boolean(entry),
|
||||
bounds,
|
||||
height: entry?.positions ? meshPositions(entry)[0][2] : 0,
|
||||
rotation: entry?.canvas
|
||||
? (entry.canvas.angle * 180) / Math.PI
|
||||
: overlay.rotation,
|
||||
drawOrder: entry?.positions ? 0 : overlay.drawOrder,
|
||||
image: Boolean(entry?.url),
|
||||
color:
|
||||
entry?.item?.fill ??
|
||||
(!entry?.url && entry?.color
|
||||
? entry.color
|
||||
: entry
|
||||
? [...(entry.canvas?.tint ?? [1, 1, 1]), entry.alpha]
|
||||
: overlay.color),
|
||||
});
|
||||
}
|
||||
const actualOrder = [
|
||||
...plan.overlays,
|
||||
...plan.meshes.filter((entry) => entry.url),
|
||||
]
|
||||
.filter((entry) => !entry.positions)
|
||||
.map((entry) => entry.record.name);
|
||||
return { entities, actualOrder, report: plan.report };
|
||||
} finally {
|
||||
loaded.revoke();
|
||||
}
|
||||
}
|
||||
|
||||
function differences(actual, expected, location, output) {
|
||||
if (typeof actual === 'number' && typeof expected === 'number') {
|
||||
const tolerance = /color|outline/.test(location)
|
||||
? tolerances.color
|
||||
: /positions.*\[2\]$|rings.*\[2\]$|\.height$/.test(location)
|
||||
? tolerances.metres
|
||||
: tolerances.degrees;
|
||||
if (!Number.isFinite(actual) || Math.abs(actual - expected) > tolerance)
|
||||
output.push(
|
||||
`${location}: expected ${expected}, got ${actual} (tolerance ${tolerance})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(expected)) {
|
||||
if (!Array.isArray(actual) || actual.length !== expected.length) {
|
||||
output.push(
|
||||
`${location}: expected ${expected.length} entries, got ${actual?.length ?? 'none'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
expected.forEach((value, i) =>
|
||||
differences(actual[i], value, `${location}[${i}]`, output),
|
||||
);
|
||||
} else if (expected && typeof expected === 'object') {
|
||||
for (const key of Object.keys(expected))
|
||||
differences(actual?.[key], expected[key], `${location}.${key}`, output);
|
||||
} else if (actual !== expected)
|
||||
output.push(
|
||||
`${location}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--record')) await record();
|
||||
else {
|
||||
const reference = JSON.parse(await fs.readFile(referencePath, 'utf8'));
|
||||
console.log(reference.recorder);
|
||||
const output = [];
|
||||
differences(files, Object.keys(reference.files).sort(), 'corpus', output);
|
||||
for (const file of files) {
|
||||
const { entities, actualOrder } = await planned(file);
|
||||
const expected = reference.files[file];
|
||||
// A rectangle rejected at the pole has no drawable style or height.
|
||||
const visible = (e) =>
|
||||
e.kind === 'overlay' && !e.drawable
|
||||
? { kind: e.kind, name: e.name, drawable: false, bounds: null }
|
||||
: e;
|
||||
const sort = (a, b) =>
|
||||
`${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`);
|
||||
differences(
|
||||
entities.map(visible).sort(sort),
|
||||
expected.map(visible).sort(sort),
|
||||
file,
|
||||
output,
|
||||
);
|
||||
const expectedOrder = expected
|
||||
.filter((e) => e.kind === 'overlay' && e.image && e.drawable && !e.height)
|
||||
.sort((a, b) => a.drawOrder - b.drawOrder)
|
||||
.map((e) => e.name);
|
||||
differences(actualOrder, expectedOrder, `${file}.drawOrder`, output);
|
||||
}
|
||||
output.forEach((line) => console.error(line));
|
||||
console.log(`${files.length} files: ${output.length} differences`);
|
||||
process.exitCode = output.length ? 1 : 0;
|
||||
}
|
||||
+20
-19
@@ -8,20 +8,19 @@ import {
|
||||
} from './geoImportRecords.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
import {
|
||||
MAX_IMPORT_BYTES,
|
||||
MAX_KMZ_INFLATED_BYTES,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_KML_LINKS,
|
||||
MAX_IMPORT_HEIGHT_M,
|
||||
IMPORT_TIMEOUT_MS,
|
||||
MAX_IMPORT_DISPLAY_NAME_LENGTH,
|
||||
} from './geoImportLimits.js';
|
||||
|
||||
export { importFeatureName };
|
||||
|
||||
/** Largest file accepted, in bytes. */
|
||||
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
|
||||
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
|
||||
/** Most files on the globe at once. */
|
||||
export const MAX_IMPORTED_FILES = 12;
|
||||
/** Most entities one file may create before it is refused. */
|
||||
export const MAX_IMPORT_ENTITIES = 20_000;
|
||||
/** Most vertices (points of every line, ring and marker) in one file. */
|
||||
export const MAX_IMPORT_POSITIONS = 250_000;
|
||||
/** Most normalized style definitions and local image links in one KML/KMZ. */
|
||||
const MAX_KML_LINKS = 2_000;
|
||||
/** What the file picker offers. */
|
||||
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
|
||||
|
||||
@@ -122,7 +121,9 @@ export function importDisplayName(name) {
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim();
|
||||
if (!clean) return 'Imported file';
|
||||
return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean;
|
||||
return clean.length > MAX_IMPORT_DISPLAY_NAME_LENGTH
|
||||
? `${clean.slice(0, MAX_IMPORT_DISPLAY_NAME_LENGTH - 3)}…`
|
||||
: clean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,7 +241,7 @@ export function countGeoJsonGeometry(
|
||||
!c.every(Number.isFinite) ||
|
||||
Math.abs(c[0]) > 180 ||
|
||||
Math.abs(c[1]) > 90 ||
|
||||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
|
||||
(c.length === 3 && Math.abs(c[2]) > MAX_IMPORT_HEIGHT_M)
|
||||
)
|
||||
throw new GeoImportError(
|
||||
msg('That GeoJSON has invalid coordinates.'),
|
||||
@@ -800,7 +801,7 @@ export function countKmlGeometry(
|
||||
const limit = ['north', 'south'].includes(name)
|
||||
? 90
|
||||
: name === 'altitude'
|
||||
? 1_000_000
|
||||
? MAX_IMPORT_HEIGHT_M
|
||||
: 360;
|
||||
if (
|
||||
!el.textContent.trim() ||
|
||||
@@ -863,7 +864,7 @@ function checkKmlPosition(tuple) {
|
||||
!numbers.every(Number.isFinite) ||
|
||||
Math.abs(numbers[0]) > 180 ||
|
||||
Math.abs(numbers[1]) > 90 ||
|
||||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
|
||||
(numbers.length === 3 && Math.abs(numbers[2]) > MAX_IMPORT_HEIGHT_M)
|
||||
)
|
||||
throw new GeoImportError(msg('That KML has invalid coordinates.'));
|
||||
}
|
||||
@@ -1123,7 +1124,7 @@ async function boundedImport(promise, signal) {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(new GeoImportError(msg('That file took too long to read.'))),
|
||||
60_000,
|
||||
IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
@@ -1147,8 +1148,8 @@ async function boundedImport(promise, signal) {
|
||||
*/
|
||||
export async function loadGeoFile(file, { color = '#39d0ff', signal } = {}) {
|
||||
signal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
|
||||
: AbortSignal.timeout(60_000);
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(IMPORT_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(IMPORT_TIMEOUT_MS);
|
||||
signal?.throwIfAborted();
|
||||
if (!file || !Number.isFinite(file.size))
|
||||
throw new GeoImportError(msg('Nothing to import.'));
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
GeoImportError,
|
||||
repairKmlRootNamespaces,
|
||||
loadGeoFile,
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
countGeoJsonGeometry,
|
||||
countKmlGeometry,
|
||||
deduplicateKmlStyles,
|
||||
@@ -23,6 +21,12 @@ import {
|
||||
readZipEntries,
|
||||
resolveKmzPath,
|
||||
} from './geoFileImport.js';
|
||||
import {
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
MAX_IMPORT_IMAGE_PIXELS,
|
||||
MAX_IMPORT_IMAGE_SIDE,
|
||||
} from './geoImportLimits.js';
|
||||
import { createImportImageBudget, importImageSize } from './geoImportImages.js';
|
||||
|
||||
test('real loader grounds XYZ GeoJSON and keeps only names and footprints in its records', async () => {
|
||||
@@ -167,13 +171,14 @@ test('image budget rejects large decoded rasters before decoding and shares repe
|
||||
v.setUint32(20, h);
|
||||
return b;
|
||||
};
|
||||
assert.deepEqual(importImageSize(png(4096, 4096)), [4096, 4096]);
|
||||
const side = Math.sqrt(MAX_IMPORT_IMAGE_PIXELS);
|
||||
assert.deepEqual(importImageSize(png(side, side)), [side, side]);
|
||||
const charge = createImportImageBudget();
|
||||
charge('shared', png(4096, 4096));
|
||||
charge('shared', png(4096, 4096));
|
||||
charge('shared', png(side, side));
|
||||
charge('shared', png(side, side));
|
||||
assert.throws(() => charge('other', png(1, 1)), /limit/);
|
||||
assert.throws(
|
||||
() => createImportImageBudget()('huge', png(16384, 16384)),
|
||||
() => createImportImageBudget()('huge', png(MAX_IMPORT_IMAGE_SIDE + 1, 1)),
|
||||
/limit/,
|
||||
);
|
||||
});
|
||||
|
||||
+146
-396
@@ -11,7 +11,7 @@
|
||||
// ignored the depth test;
|
||||
// - ground overlays: image layers, or textured meshes at absolute altitudes,
|
||||
// tinted and turned on a canvas when the KML says so; a plain colour is
|
||||
// a ground fill.
|
||||
// a ground fill or a mesh at an absolute altitude.
|
||||
// Nothing is pickable: an imported file has no card. Ground heights under
|
||||
// the points come from engine.surface, sampled again by refreshGround():
|
||||
// the terrain where the globe is drawn, else (3D tiles) the scene drawn
|
||||
@@ -19,35 +19,17 @@
|
||||
// pins out of height queries).
|
||||
|
||||
import * as geo from '../geo/index.js';
|
||||
import { normalizeGroundItem } from '../globe/ground.js';
|
||||
import { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.js';
|
||||
import { planImport } from './geoImportPlan.js';
|
||||
import {
|
||||
BATCH,
|
||||
HORIZON_LIFT_M,
|
||||
CLAMP_TILES_BELOW_M,
|
||||
MAX_ICON_PIXEL_RATIO,
|
||||
POLYGON_YIELD_MS,
|
||||
} from './geoImportLimits.js';
|
||||
import { composeIcon } from './geoImportPins.js';
|
||||
import { recordFootprint } from './geoImportRecords.js';
|
||||
import { emptyRecords, recordFootprint } from './geoImportRecords.js';
|
||||
|
||||
const POLYGON_ORDER = 0;
|
||||
const LINE_ORDER = 1;
|
||||
// Over the layers' rasters, as Cesium's ground rectangle drew over imagery.
|
||||
const OVERLAY_ORDER = 30;
|
||||
// The horizon test lifts each point by this much (as GhostMaps does):
|
||||
// ground can lie below the ellipsoid.
|
||||
const HORIZON_LIFT_M = 2000;
|
||||
// Under this camera height, points on 3D tiles are clamped to the tiles
|
||||
// drawn now; higher up the terrain answers (Cesium's 250 km).
|
||||
const CLAMP_TILES_BELOW_M = 250_000;
|
||||
const BATCH = 256;
|
||||
// Canvas pixels every imported icon look shares (64 MiB of RGBA).
|
||||
const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
|
||||
// Canvas pixels every tinted or turned ground overlay of a file shares.
|
||||
const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024;
|
||||
// The longest side of a tinted or turned ground overlay's canvas.
|
||||
const MAX_OVERLAY_SIDE = 4096;
|
||||
// Bound main-thread triangulation stalls and the number of raised outlines.
|
||||
// Median polygonShape times (three runs on a fast laptop): 32,768 vertices
|
||||
// took 58 ms (37 ms in 128 rings), versus 198 ms for 65,536 vertices.
|
||||
// Holes make earcut quadratic: 1,000 took 110 ms and 10,000 took 4.8 s.
|
||||
// Past the vertex limit only outlines draw; past the ring limit nothing draws.
|
||||
const MAX_RAISED_RINGS = 128;
|
||||
const MAX_RAISED_VERTICES = 32_768;
|
||||
let serial = 0;
|
||||
// Each engine's imported point sets, left out of each other's ground.
|
||||
const pointSets = new WeakMap();
|
||||
@@ -64,105 +46,27 @@ const imageFromUrl = async (url) => {
|
||||
return image;
|
||||
};
|
||||
|
||||
// A ground item the contract accepts, or null (a degenerate ring or line).
|
||||
function checked(kind, item) {
|
||||
try {
|
||||
normalizeGroundItem(kind, item);
|
||||
return item;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A ground overlay's box with east past west across 180°, and how a corner
|
||||
// turns by its rotation: KML's degrees counter-clockwise about the centre,
|
||||
// in longitude and latitude (Cesium's rectangle rotation).
|
||||
function overlayBox({ rectangle, rotation = 0 }) {
|
||||
const { west, south, north } = rectangle;
|
||||
const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
|
||||
const box = { west, south, east, north };
|
||||
const center = [(west + east) / 2, (south + north) / 2];
|
||||
const angle = (rotation * Math.PI) / 180;
|
||||
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
|
||||
const turn = ([lon, lat]) => {
|
||||
const [x, y] = [lon - center[0], lat - center[1]];
|
||||
return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
|
||||
};
|
||||
return { box, center, angle, turn };
|
||||
}
|
||||
|
||||
// What a ground overlay's image layer draws: its image tinted by the
|
||||
// overlay's colour and turned by its rotation onto the box around the
|
||||
// turned image (transparent outside it). Null when that box passes a pole.
|
||||
async function overlayImage(overlay, loadImage, createCanvas, maxSide, cache) {
|
||||
const tint = overlay.color.slice(0, 3);
|
||||
const { box, center, angle } = overlayBox(overlay);
|
||||
if (tint.every((value) => value === 1) && !angle)
|
||||
return { url: overlay.url, rectangle: overlay.rectangle };
|
||||
const [width, height] = [box.east - box.west, box.north - box.south];
|
||||
const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
|
||||
const [spanX, spanY] = [
|
||||
width * cos + height * sin,
|
||||
width * sin + height * cos,
|
||||
];
|
||||
const rectangle = {
|
||||
west: center[0] - spanX / 2,
|
||||
south: center[1] - spanY / 2,
|
||||
east: center[0] + spanX / 2,
|
||||
north: center[1] + spanY / 2,
|
||||
};
|
||||
if (angle && (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360))
|
||||
return null;
|
||||
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
|
||||
const bounds = angle
|
||||
? { ...rectangle, west: wrap(rectangle.west), east: wrap(rectangle.east) }
|
||||
: overlay.rectangle;
|
||||
const key = JSON.stringify([overlay.url, tint, angle, width / height]);
|
||||
if (cache.has(key)) return { image: cache.get(key), rectangle: bounds };
|
||||
let image;
|
||||
try {
|
||||
image = await loadImage(overlay.url);
|
||||
} catch {
|
||||
// An image that does not decode draws nothing, as on the plain path.
|
||||
return { url: overlay.url, rectangle: overlay.rectangle };
|
||||
}
|
||||
const natural = [
|
||||
image.naturalWidth || image.width,
|
||||
image.naturalHeight || image.height,
|
||||
];
|
||||
// Pixels per degree: one scale on both axes when turned, so the image
|
||||
// turns in degrees as Cesium's does (the canvas budget bounds its size).
|
||||
let [scaleX, scaleY] = [natural[0] / width, natural[1] / height];
|
||||
if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
|
||||
const fit = Math.min(
|
||||
1,
|
||||
maxSide / (spanX * scaleX),
|
||||
maxSide / (spanY * scaleY),
|
||||
);
|
||||
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
|
||||
// Execute the canvas recipe; geometry and pixel budgets are decided by the plan.
|
||||
function overlayImage(entry, images, createCanvas, cache) {
|
||||
const recipe = entry.canvas;
|
||||
if (!recipe) return images.get(entry.url);
|
||||
if (cache.has(recipe.key)) return cache.get(recipe.key);
|
||||
const canvas = createCanvas();
|
||||
canvas.width = Math.max(1, Math.round(spanX * scaleX));
|
||||
canvas.height = Math.max(1, Math.round(spanY * scaleY));
|
||||
canvas.width = recipe.width;
|
||||
canvas.height = recipe.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
// The canvas's y runs south: a counter-clockwise turn is a negative one.
|
||||
context.rotate(-angle);
|
||||
context.drawImage(
|
||||
image,
|
||||
(-width * scaleX) / 2,
|
||||
(-height * scaleY) / 2,
|
||||
width * scaleX,
|
||||
height * scaleY,
|
||||
);
|
||||
if (tint.some((value) => value !== 1)) {
|
||||
context.rotate(-recipe.angle);
|
||||
context.drawImage(images.get(entry.url), ...recipe.draw);
|
||||
if (recipe.tint.some((value) => value !== 1)) {
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let i = 0; i < pixels.data.length; i += 4)
|
||||
for (let channel = 0; channel < 3; channel++)
|
||||
pixels.data[i + channel] *= tint[channel];
|
||||
pixels.data[i + channel] *= recipe.tint[channel];
|
||||
context.putImageData(pixels, 0, 0);
|
||||
}
|
||||
cache.set(key, canvas);
|
||||
return { image: canvas, rectangle: bounds };
|
||||
cache.set(recipe.key, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// KML headings are from north (Cesium's billboard on the Earth's axis):
|
||||
@@ -170,24 +74,6 @@ async function overlayImage(overlay, loadImage, createCanvas, maxSide, cache) {
|
||||
const turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation);
|
||||
const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0);
|
||||
|
||||
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
|
||||
const cartesian = ([lon, lat, height = 0]) =>
|
||||
geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
|
||||
/** One key per drawn look of an icon, so equal pins share a sprite set. */
|
||||
const lookKey = (icon, depth) =>
|
||||
JSON.stringify([
|
||||
icon.image,
|
||||
icon.widthPx,
|
||||
icon.heightPx,
|
||||
icon.scale,
|
||||
icon.origin,
|
||||
icon.offsetPx,
|
||||
icon.subRegion,
|
||||
icon.scaleByDistance,
|
||||
depth,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Draw one imported file's records on `engine`. Resolves once its icons
|
||||
* are decoded and every set is committed.
|
||||
@@ -212,7 +98,10 @@ export async function drawImportedFile(
|
||||
visible = true,
|
||||
loadImage = imageFromUrl,
|
||||
createCanvas = () => document.createElement('canvas'),
|
||||
pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)),
|
||||
pixelRatio = Math.max(
|
||||
1,
|
||||
Math.min(MAX_ICON_PIXEL_RATIO, globalThis.devicePixelRatio || 1),
|
||||
),
|
||||
now = () => performance.now(),
|
||||
signal,
|
||||
isCurrent = () => true,
|
||||
@@ -227,7 +116,7 @@ export async function drawImportedFile(
|
||||
const lines = [];
|
||||
const meshes = [];
|
||||
const pins = [];
|
||||
const kinds = { ...records.undrawn };
|
||||
const kinds = {};
|
||||
const layers = [];
|
||||
const looks = new Map();
|
||||
let drawn = 0;
|
||||
@@ -256,276 +145,137 @@ export async function drawImportedFile(
|
||||
|
||||
try {
|
||||
checkActive();
|
||||
// Fills under ground lines; a plain-colour overlay is a fill too.
|
||||
const fills = [];
|
||||
const ground = [];
|
||||
const images = new Map();
|
||||
const metadata = new Map();
|
||||
const overlayCache = new Map();
|
||||
function drawPlan(plan) {
|
||||
drawn += plan.report.drawn;
|
||||
for (const [kind, count] of Object.entries(plan.report.kinds))
|
||||
kinds[kind] = (kinds[kind] ?? 0) + count;
|
||||
ground.push(...plan.ground);
|
||||
for (const entry of plan.meshes) {
|
||||
const { positions, indices, color, uv } = entry;
|
||||
meshes.push(
|
||||
engine.meshes.createMesh({
|
||||
id: `${prefix}-mesh-${meshes.length}`,
|
||||
positions,
|
||||
indices,
|
||||
color,
|
||||
show: shown,
|
||||
...(uv
|
||||
? {
|
||||
uv,
|
||||
image: overlayImage(
|
||||
entry,
|
||||
images,
|
||||
createCanvas,
|
||||
overlayCache,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const entry of plan.lines) {
|
||||
const line = engine.lines.createLine({
|
||||
id: `${prefix}-line-${lines.length}`,
|
||||
widthPx: entry.widthPx,
|
||||
color: entry.color,
|
||||
});
|
||||
line.setPositions(entry.positions);
|
||||
line.show(shown);
|
||||
lines.push(line);
|
||||
}
|
||||
for (const entry of plan.overlays) {
|
||||
const image =
|
||||
entry.canvas &&
|
||||
overlayImage(entry, images, createCanvas, overlayCache);
|
||||
layers.push(
|
||||
engine.imagery.createLayer({
|
||||
id: `${prefix}-overlay-${layers.length}`,
|
||||
source: {
|
||||
kind: 'image',
|
||||
rectangle: entry.rectangle,
|
||||
...(image ? { image } : { url: entry.url }),
|
||||
},
|
||||
alpha: entry.alpha,
|
||||
show: shown,
|
||||
order: entry.order,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
let lastYield = now();
|
||||
for (const polygon of records.polygons) {
|
||||
if (now() - lastYield > 8) {
|
||||
if (now() - lastYield > POLYGON_YIELD_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
checkActive();
|
||||
lastYield = now();
|
||||
}
|
||||
let any = false;
|
||||
if (polygon.raised && polygon.rings.length > MAX_RAISED_RINGS) {
|
||||
kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (polygon.fill && polygon.raised) {
|
||||
const vertices = polygon.rings.reduce((n, ring) => n + ring.length, 0);
|
||||
const shape =
|
||||
vertices <= MAX_RAISED_VERTICES && polygonShape(polygon.rings);
|
||||
if (vertices > MAX_RAISED_VERTICES)
|
||||
kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1;
|
||||
if (shape) {
|
||||
meshes.push(
|
||||
engine.meshes.createMesh({
|
||||
id: `${prefix}-fill-${meshes.length}`,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
color: polygon.fill,
|
||||
show: shown,
|
||||
}),
|
||||
);
|
||||
any = true;
|
||||
}
|
||||
} else if (polygon.fill) {
|
||||
const item = checked('polygon', {
|
||||
rings: polygon.rings.map(flat),
|
||||
fill: polygon.fill,
|
||||
});
|
||||
if (item) {
|
||||
fills.push(item);
|
||||
any = true;
|
||||
} else kinds.polygon = (kinds.polygon ?? 0) + 1;
|
||||
}
|
||||
if (polygon.raised && polygon.outline)
|
||||
for (const ring of polygon.rings) {
|
||||
if (ring.length < 2) continue;
|
||||
const closed = [...ring, ring[0]].map(cartesian);
|
||||
const line = engine.lines.createLine({
|
||||
id: `${prefix}-outline-${lines.length}`,
|
||||
widthPx: 1,
|
||||
color: polygon.outline.color,
|
||||
});
|
||||
line.setPositions(closed);
|
||||
line.show(shown);
|
||||
lines.push(line);
|
||||
any = true;
|
||||
}
|
||||
if (any) drawn++;
|
||||
drawPlan(planImport({ ...emptyRecords(), polygons: [polygon] }));
|
||||
}
|
||||
const overlayCache = new Map();
|
||||
const overlayImages = new Map();
|
||||
const overlaySide = Math.min(
|
||||
MAX_OVERLAY_SIDE,
|
||||
Math.floor(
|
||||
Math.sqrt(OVERLAY_PIXEL_BUDGET / Math.max(1, records.overlays.length)),
|
||||
),
|
||||
);
|
||||
// KML drawOrder, then the document's: equal layer orders draw in
|
||||
// creation order, and a file imported later over this one.
|
||||
const overlays = [...records.overlays].sort(
|
||||
(a, b) => (a.drawOrder ?? 0) - (b.drawOrder ?? 0),
|
||||
);
|
||||
for (const overlay of overlays) {
|
||||
// Cesium raises only an absolute overlay, to its altitude.
|
||||
const raise =
|
||||
overlay.altitudeMode === 'absolute' && overlay.altitude > 0
|
||||
? overlay.altitude
|
||||
: undefined;
|
||||
if (overlay.url) {
|
||||
const prepared = await overlayImage(
|
||||
overlay,
|
||||
loadImage,
|
||||
createCanvas,
|
||||
overlaySide,
|
||||
overlayCache,
|
||||
);
|
||||
checkActive();
|
||||
if (!prepared) {
|
||||
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (raise) {
|
||||
let image = prepared.image ?? overlayImages.get(overlay.url);
|
||||
if (!image) {
|
||||
try {
|
||||
image = await loadImage(overlay.url);
|
||||
overlayImages.set(overlay.url, image);
|
||||
} catch {
|
||||
checkActive();
|
||||
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
checkActive();
|
||||
}
|
||||
const { west, south, east, north } = prepared.rectangle;
|
||||
const shape = rectangleShape([west, south, east, north], raise);
|
||||
meshes.push(
|
||||
engine.meshes.createMesh({
|
||||
id: `${prefix}-overlay-${meshes.length}`,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
uv: shape.attributes.st.values,
|
||||
image,
|
||||
color: [1, 1, 1, Math.min(0.99, overlay.color[3])],
|
||||
show: shown,
|
||||
}),
|
||||
);
|
||||
drawn++;
|
||||
continue;
|
||||
}
|
||||
layers.push(
|
||||
engine.imagery.createLayer({
|
||||
id: `${prefix}-overlay-${layers.length}`,
|
||||
source: { kind: 'image', ...prepared },
|
||||
// Cesium's transparent image material: at most 0.99.
|
||||
alpha: Math.min(0.99, overlay.color[3]),
|
||||
show: shown,
|
||||
order: OVERLAY_ORDER,
|
||||
}),
|
||||
);
|
||||
drawn++;
|
||||
continue;
|
||||
}
|
||||
// A plain colour draws as a ground fill.
|
||||
if (raise) {
|
||||
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
const { west, south, east, north } = overlay.rectangle;
|
||||
const item = checked('cell', { west, south, east, north });
|
||||
if (!item) continue;
|
||||
const { box, turn } = overlayBox(overlay);
|
||||
const rings = [
|
||||
[
|
||||
[box.west, box.south],
|
||||
[box.east, box.south],
|
||||
[box.east, box.north],
|
||||
[box.west, box.north],
|
||||
].map(turn),
|
||||
];
|
||||
const fill = checked('polygon', { rings, fill: overlay.color });
|
||||
if (!fill) {
|
||||
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
fills.push(fill);
|
||||
drawn++;
|
||||
}
|
||||
if (fills.length) {
|
||||
const set = engine.ground.createSet({
|
||||
id: `${prefix}-polygons`,
|
||||
kind: 'polygon',
|
||||
order: POLYGON_ORDER,
|
||||
});
|
||||
groundSets.push(set);
|
||||
set.replace(fills);
|
||||
set.commit();
|
||||
set.show(shown);
|
||||
}
|
||||
|
||||
const corridors = [];
|
||||
for (const record of records.lines) {
|
||||
if (record.ground) {
|
||||
const item = checked('corridor', {
|
||||
positions: flat(record.positions),
|
||||
color: record.color,
|
||||
widthPx: record.widthPx,
|
||||
});
|
||||
if (!item) continue;
|
||||
corridors.push(item);
|
||||
} else {
|
||||
const line = engine.lines.createLine({
|
||||
id: `${prefix}-line-${lines.length}`,
|
||||
widthPx: record.widthPx,
|
||||
color: record.color,
|
||||
});
|
||||
line.setPositions(record.positions.map(cartesian));
|
||||
line.show(shown);
|
||||
lines.push(line);
|
||||
}
|
||||
drawn++;
|
||||
}
|
||||
if (corridors.length) {
|
||||
const set = engine.ground.createSet({
|
||||
id: `${prefix}-lines`,
|
||||
kind: 'corridor',
|
||||
order: LINE_ORDER,
|
||||
});
|
||||
groundSets.push(set);
|
||||
set.replace(corridors);
|
||||
set.commit();
|
||||
set.show(shown);
|
||||
}
|
||||
|
||||
for (const point of records.points) {
|
||||
const depth = point.altitude === 'absolute' ? 'normal' : 'always';
|
||||
const key = lookKey(point.icon, depth);
|
||||
let look = looks.get(key);
|
||||
if (!look)
|
||||
looks.set(
|
||||
key,
|
||||
(look = { icon: point.icon, depth, pins: [], set: null, count: 0 }),
|
||||
);
|
||||
const [lon, lat, height = 0] = point.position;
|
||||
look.pins.push({
|
||||
lon,
|
||||
lat,
|
||||
offset: point.altitude === 'relative' ? height : 0,
|
||||
height: point.altitude === 'absolute' ? height : null,
|
||||
ground: undefined,
|
||||
position: null,
|
||||
horizon: null,
|
||||
show: true,
|
||||
color: point.icon.color ?? [1, 1, 1, 1],
|
||||
rotation: point.icon.rotation ?? 0,
|
||||
});
|
||||
}
|
||||
pins.push(...[...looks.values()].flatMap((look) => look.pins));
|
||||
const maxSide = Math.min(
|
||||
512,
|
||||
Math.floor(Math.sqrt(ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
|
||||
);
|
||||
const remaining = { ...records, polygons: [] };
|
||||
const pending = planImport(remaining);
|
||||
await Promise.all(
|
||||
[...looks.values()].map(async (look) => {
|
||||
const url = look.icon.image?.url;
|
||||
let image;
|
||||
if (url) {
|
||||
try {
|
||||
image = await loadImage(url);
|
||||
} catch {
|
||||
// An icon that does not decode draws nothing, as a billboard
|
||||
// without an image.
|
||||
return;
|
||||
}
|
||||
pending.images.map(async (url) => {
|
||||
try {
|
||||
const image = await loadImage(url);
|
||||
images.set(url, image);
|
||||
metadata.set(url, {
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
});
|
||||
} catch {
|
||||
metadata.set(url, null);
|
||||
}
|
||||
checkActive();
|
||||
const { canvas, side } = composeIcon(look.icon, {
|
||||
image,
|
||||
createCanvas,
|
||||
ratio: pixelRatio,
|
||||
maxSide,
|
||||
});
|
||||
look.set = engine.movers.createSet({
|
||||
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
|
||||
kind: 'sprite',
|
||||
capacity: look.pins.length,
|
||||
depthTest: look.depth,
|
||||
pickable: false,
|
||||
sprite: {
|
||||
sizePx: side,
|
||||
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
|
||||
...(look.icon.scaleByDistance
|
||||
? { scaleByDistance: [...look.icon.scaleByDistance] }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
pointSetsOf(engine).add(look.set);
|
||||
drawn += look.pins.length;
|
||||
}),
|
||||
);
|
||||
checkActive();
|
||||
const plan = planImport(remaining, undefined, { images: metadata });
|
||||
drawPlan(plan);
|
||||
for (const kind of ['polygon', 'corridor']) {
|
||||
const entries = ground.filter((entry) => entry.kind === kind);
|
||||
if (!entries.length) continue;
|
||||
const set = engine.ground.createSet({
|
||||
id: `${prefix}-${kind}`,
|
||||
kind,
|
||||
order: entries[0].order,
|
||||
});
|
||||
groundSets.push(set);
|
||||
set.replace(entries.map((entry) => entry.item));
|
||||
set.commit();
|
||||
set.show(shown);
|
||||
}
|
||||
for (const planned of plan.pins) {
|
||||
const look = { ...planned, set: null, count: 0 };
|
||||
looks.set(looks.size, look);
|
||||
pins.push(...look.pins);
|
||||
if (!look.draw) continue;
|
||||
const { canvas, side } = composeIcon(look.icon, {
|
||||
image: images.get(look.icon.image?.url),
|
||||
createCanvas,
|
||||
ratio: pixelRatio,
|
||||
maxSide: look.maxSide,
|
||||
});
|
||||
look.set = engine.movers.createSet({
|
||||
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
|
||||
kind: 'sprite',
|
||||
capacity: look.pins.length,
|
||||
depthTest: look.depth,
|
||||
pickable: false,
|
||||
sprite: {
|
||||
sizePx: side,
|
||||
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
|
||||
...(look.icon.scaleByDistance
|
||||
? { scaleByDistance: [...look.icon.scaleByDistance] }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
pointSetsOf(engine).add(look.set);
|
||||
}
|
||||
checkActive();
|
||||
for (let from = 0; from < pins.length; from += BATCH) {
|
||||
settle(heightContext(), from, from + BATCH);
|
||||
if (from + BATCH < pins.length) {
|
||||
|
||||
@@ -3,6 +3,12 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
|
||||
import {
|
||||
MAX_RAISED_RINGS,
|
||||
MAX_RAISED_VERTICES,
|
||||
OVERLAY_PIXEL_BUDGET,
|
||||
BATCH,
|
||||
} from './geoImportLimits.js';
|
||||
import { drawImportedFile } from './geoImportDisplay.js';
|
||||
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
|
||||
|
||||
@@ -434,7 +440,7 @@ for (const make of GROUND_HARNESSES) {
|
||||
},
|
||||
);
|
||||
|
||||
for (const rings of [128, 200])
|
||||
for (const rings of [MAX_RAISED_RINGS, MAX_RAISED_RINGS + 1])
|
||||
displayTest(
|
||||
`a raised polygon with ${rings} rings respects the ring bound`,
|
||||
async ({ engine }) => {
|
||||
@@ -469,12 +475,18 @@ for (const make of GROUND_HARNESSES) {
|
||||
});
|
||||
assert.deepEqual(
|
||||
drawing.report,
|
||||
rings === 128
|
||||
rings === MAX_RAISED_RINGS
|
||||
? { drawn: 1, undrawn: 0, kinds: {} }
|
||||
: { drawn: 0, undrawn: 1, kinds: { complexPolygon: 1 } },
|
||||
);
|
||||
assert.equal(engine.meshes.list().length, rings === 128 ? 1 : 0);
|
||||
assert.equal(engine.lines.list().length, rings === 128 ? rings : 0);
|
||||
assert.equal(
|
||||
engine.meshes.list().length,
|
||||
rings === MAX_RAISED_RINGS ? 1 : 0,
|
||||
);
|
||||
assert.equal(
|
||||
engine.lines.list().length,
|
||||
rings === MAX_RAISED_RINGS ? rings : 0,
|
||||
);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
@@ -519,11 +531,11 @@ for (const make of GROUND_HARNESSES) {
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a raised fill with 40 000 vertices is counted, its outline drawn',
|
||||
'a raised fill past the vertex limit is counted, its outline drawn',
|
||||
async ({ engine }) => {
|
||||
const records = emptyRecords();
|
||||
const outer = Array.from({ length: 40_000 }, (_, i) => {
|
||||
const angle = (i * 2 * Math.PI) / 40_000;
|
||||
const outer = Array.from({ length: MAX_RAISED_VERTICES + 1 }, (_, i) => {
|
||||
const angle = (i * 2 * Math.PI) / (MAX_RAISED_VERTICES + 1);
|
||||
return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000];
|
||||
});
|
||||
records.polygons.push({
|
||||
@@ -684,7 +696,7 @@ for (const make of GROUND_HARNESSES) {
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a polygon the ground contract rejects is counted undrawn',
|
||||
'a ground polygon skips a short hole as Cesium does',
|
||||
async ({ engine }) => {
|
||||
const records = geoJsonRecords(
|
||||
{
|
||||
@@ -706,9 +718,9 @@ for (const make of GROUND_HARNESSES) {
|
||||
]);
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.deepEqual(drawing.report, {
|
||||
drawn: 0,
|
||||
undrawn: 1,
|
||||
kinds: { polygon: 1 },
|
||||
drawn: 1,
|
||||
undrawn: 0,
|
||||
kinds: {},
|
||||
});
|
||||
drawing.destroy();
|
||||
},
|
||||
@@ -1054,7 +1066,6 @@ for (const make of GROUND_HARNESSES) {
|
||||
},
|
||||
};
|
||||
};
|
||||
const OVERLAY_PIXELS = 16 * 1024 * 1024;
|
||||
|
||||
displayTest(
|
||||
'turned overlays sharing one small image share one bounded canvas',
|
||||
@@ -1075,7 +1086,7 @@ for (const make of GROUND_HARNESSES) {
|
||||
assert.equal(drawing.report.drawn, 20);
|
||||
assert.equal(counted.canvases.length, 1, 'equal looks share a canvas');
|
||||
// Each of 20 overlays may hold a twentieth of the budget.
|
||||
const side = Math.floor(Math.sqrt(OVERLAY_PIXELS / 20));
|
||||
const side = Math.floor(Math.sqrt(OVERLAY_PIXEL_BUDGET / 20));
|
||||
const [canvas] = counted.canvases;
|
||||
assert.ok(
|
||||
canvas.width <= side && canvas.height <= side,
|
||||
@@ -1104,7 +1115,7 @@ for (const make of GROUND_HARNESSES) {
|
||||
assert.equal(drawing.report.drawn, 100);
|
||||
assert.equal(counted.canvases.length, 100);
|
||||
assert.ok(
|
||||
counted.pixels() <= OVERLAY_PIXELS,
|
||||
counted.pixels() <= OVERLAY_PIXEL_BUDGET,
|
||||
`${Math.round((counted.pixels() * 4) / 2 ** 20)} MiB of canvas RGBA`,
|
||||
);
|
||||
drawing.destroy();
|
||||
@@ -1275,7 +1286,7 @@ for (const make of GROUND_HARNESSES) {
|
||||
}, 0);
|
||||
await drawing.refreshGround();
|
||||
clearTimeout(heartbeat);
|
||||
assert.equal(beforeYield, 256);
|
||||
assert.equal(beforeYield, BATCH);
|
||||
assert.equal(clamps, 600);
|
||||
drawing.destroy();
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Inspect encoded headers before allowing the browser to decode local images.
|
||||
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_SIDE = 8192;
|
||||
import {
|
||||
MAX_IMPORT_IMAGE_PIXELS,
|
||||
MAX_IMPORT_IMAGE_SIDE,
|
||||
} from './geoImportLimits.js';
|
||||
|
||||
/** Width and height from a supported raster header; no image is decoded. */
|
||||
export function importImageSize(bytes) {
|
||||
@@ -62,7 +64,9 @@ export function createImportImageBudget() {
|
||||
const size = importImageSize(bytes);
|
||||
if (
|
||||
!size ||
|
||||
size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_SIDE) ||
|
||||
size.some(
|
||||
(n) => !Number.isInteger(n) || n <= 0 || n > MAX_IMPORT_IMAGE_SIDE,
|
||||
) ||
|
||||
pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
|
||||
)
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Import resource ceilings and placement thresholds shared by readers and drawing.
|
||||
// Values without a timing measurement are the existing import policy budgets.
|
||||
|
||||
// Bound the file read and XML/JSON parse; the picker policy allows 50 MiB.
|
||||
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
// Stop zip bombs before allocation; the KMZ policy allows 120 MiB inflated.
|
||||
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
|
||||
// Bound retained files and drawing sets; the session UI policy allows twelve.
|
||||
export const MAX_IMPORTED_FILES = 12;
|
||||
// Bound drawing objects per file; the import policy allows 20,000 entities.
|
||||
export const MAX_IMPORT_ENTITIES = 20_000;
|
||||
// Bound total coordinate work; the import policy allows 250,000 positions.
|
||||
export const MAX_IMPORT_POSITIONS = 250_000;
|
||||
// Bound style/link normalization and image lookup; policy allows 2,000 links.
|
||||
export const MAX_KML_LINKS = 2_000;
|
||||
// Reject implausible coordinates; the reader policy accepts heights to 1,000 km.
|
||||
export const MAX_IMPORT_HEIGHT_M = 1_000_000;
|
||||
// Bound pending reads; the existing reader deadline is one minute.
|
||||
export const IMPORT_TIMEOUT_MS = 60_000;
|
||||
// Bound feature labels retained in records; the existing label policy is 120 chars.
|
||||
export const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
// Bound file names in the import list; the existing row policy is 60 chars.
|
||||
export const MAX_IMPORT_DISPLAY_NAME_LENGTH = 60;
|
||||
// Bound decoded local images to 64 MiB RGBA per file, from the image memory budget.
|
||||
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
// Reject oversized raster axes before decoding; the existing image ceiling is 8K.
|
||||
export const MAX_IMPORT_IMAGE_SIDE = 8192;
|
||||
// Bound sprite canvases to 64 MiB RGBA per file, from the icon memory budget.
|
||||
export const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
|
||||
// Bound transformed overlay canvases to 64 MiB RGBA, from the overlay memory budget.
|
||||
export const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024;
|
||||
// Bound each transformed overlay texture; the existing canvas ceiling is 4K.
|
||||
export const MAX_OVERLAY_SIDE = 4096;
|
||||
// Bound each icon canvas; the existing sprite ceiling is 512 pixels.
|
||||
export const MAX_ICON_SIDE = 512;
|
||||
// Bound high-DPI sprite memory; the existing raster scale ceiling is 2x.
|
||||
export const MAX_ICON_PIXEL_RATIO = 2;
|
||||
// Bound triangulation: measured 32,768 vertices in 58 ms, versus 198 ms at 65,536.
|
||||
export const MAX_RAISED_VERTICES = 32_768;
|
||||
// Bound holes and outlines: 128 rings took 37 ms; 1,000 holes took 110 ms.
|
||||
export const MAX_RAISED_RINGS = 128;
|
||||
// Yield between polygons after half a 60 Hz frame, preserving the 8 ms work slice.
|
||||
export const POLYGON_YIELD_MS = 8;
|
||||
// Bound synchronous surface queries between yields; the existing batch is 256 pins.
|
||||
export const BATCH = 256;
|
||||
// Keep below-ellipsoid ground visible at the horizon, matching GhostMaps' 2 km lift.
|
||||
export const HORIZON_LIFT_M = 2000;
|
||||
// Limit tile clamping to a low camera, matching Cesium's 250 km threshold.
|
||||
export const CLAMP_TILES_BELOW_M = 250_000;
|
||||
|
||||
// Cesium draws ground lines over fills and ground rectangles over layer rasters.
|
||||
export const POLYGON_ORDER = 0;
|
||||
export const LINE_ORDER = 1;
|
||||
export const OVERLAY_ORDER = 30;
|
||||
@@ -4,6 +4,8 @@
|
||||
// whose centre is the point (src/data/geoImportDisplay.js), so the pin's
|
||||
// tip, a KML hot spot or an icon's sub-region lands where Cesium put it.
|
||||
|
||||
import { MAX_ICON_SIDE } from './geoImportLimits.js';
|
||||
|
||||
/** Cesium.Color's CSS form of rgba in 0..1 (its float-to-byte conversion). */
|
||||
export function cssOf([red, green, blue, alpha = 1]) {
|
||||
const byte = (v) => (v === 1 ? 255 : (v * 256) | 0);
|
||||
@@ -89,7 +91,7 @@ export function iconBox(icon, natural) {
|
||||
*/
|
||||
export function composeIcon(
|
||||
icon,
|
||||
{ image, createCanvas, ratio = 1, maxSide = 512 } = {},
|
||||
{ image, createCanvas, ratio = 1, maxSide = MAX_ICON_SIDE } = {},
|
||||
) {
|
||||
const natural = image
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// Pure drawing decisions for imported records. Image metadata is supplied by
|
||||
// the caller; this module neither decodes images nor talks to a globe engine.
|
||||
import * as geo from '../geo/index.js';
|
||||
import { normalizeGroundItem } from '../globe/ground.js';
|
||||
import { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.js';
|
||||
import { hasRingVertices } from './geoImportRecords.js';
|
||||
import * as defaults from './geoImportLimits.js';
|
||||
|
||||
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
|
||||
const cartesian = ([lon, lat, height = 0]) =>
|
||||
geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
|
||||
// A ground item the contract accepts, or null (a degenerate ring or line).
|
||||
function checked(kind, item) {
|
||||
try {
|
||||
normalizeGroundItem(kind, item);
|
||||
return item;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A ground overlay's box with east past west across 180°, and how a corner
|
||||
// turns by its rotation: KML's degrees counter-clockwise about the centre,
|
||||
// in longitude and latitude (Cesium's rectangle rotation).
|
||||
function overlayBox({ rectangle, rotation = 0 }) {
|
||||
const { west, south, north } = rectangle;
|
||||
const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
|
||||
const box = { west, south, east, north };
|
||||
const center = [(west + east) / 2, (south + north) / 2];
|
||||
const angle = (rotation * Math.PI) / 180;
|
||||
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
|
||||
const turn = ([lon, lat]) => {
|
||||
const [x, y] = [lon - center[0], lat - center[1]];
|
||||
return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
|
||||
};
|
||||
return { box, center, angle, turn };
|
||||
}
|
||||
|
||||
/** One key per drawn look of an icon, so equal pins share a sprite set. */
|
||||
const lookKey = (icon, depth) =>
|
||||
JSON.stringify([
|
||||
icon.image,
|
||||
icon.widthPx,
|
||||
icon.heightPx,
|
||||
icon.scale,
|
||||
icon.origin,
|
||||
icon.offsetPx,
|
||||
icon.subRegion,
|
||||
icon.scaleByDistance,
|
||||
depth,
|
||||
]);
|
||||
|
||||
// Canvas coordinates use one scale on both axes for rotations in degrees.
|
||||
function overlayCanvas(overlay, maxSide, natural) {
|
||||
const tint = overlay.color.slice(0, 3);
|
||||
const { box, center, angle } = overlayBox(overlay);
|
||||
if (tint.every((value) => value === 1) && !angle)
|
||||
return { rectangle: overlay.rectangle, canvas: null };
|
||||
const [width, height] = [box.east - box.west, box.north - box.south];
|
||||
const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
|
||||
const [spanX, spanY] = [
|
||||
width * cos + height * sin,
|
||||
width * sin + height * cos,
|
||||
];
|
||||
const rectangle = {
|
||||
west: center[0] - spanX / 2,
|
||||
south: center[1] - spanY / 2,
|
||||
east: center[0] + spanX / 2,
|
||||
north: center[1] + spanY / 2,
|
||||
};
|
||||
if (angle && (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360))
|
||||
return null;
|
||||
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
|
||||
const bounds = angle
|
||||
? { ...rectangle, west: wrap(rectangle.west), east: wrap(rectangle.east) }
|
||||
: overlay.rectangle;
|
||||
// A failed decode keeps the original image-layer path, as a plain image does.
|
||||
if (natural === null) return { rectangle: overlay.rectangle, canvas: null };
|
||||
let [scaleX, scaleY] = [
|
||||
(natural?.width ?? 1) / width,
|
||||
(natural?.height ?? 1) / height,
|
||||
];
|
||||
if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
|
||||
const fit = Math.min(
|
||||
1,
|
||||
maxSide / (spanX * scaleX),
|
||||
maxSide / (spanY * scaleY),
|
||||
);
|
||||
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
|
||||
return {
|
||||
rectangle: bounds,
|
||||
canvas: {
|
||||
key: JSON.stringify([overlay.url, tint, angle, width / height]),
|
||||
width: Math.max(1, Math.round(spanX * scaleX)),
|
||||
height: Math.max(1, Math.round(spanY * scaleY)),
|
||||
angle,
|
||||
tint,
|
||||
draw: [
|
||||
(-width * scaleX) / 2,
|
||||
(-height * scaleY) / 2,
|
||||
width * scaleX,
|
||||
height * scaleY,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what one file draws, without a DOM or engine. `limits` overrides the
|
||||
* shared ceilings for tests. `context.images` is a Map of URL to {width, height},
|
||||
* or null for a failed decode. `images` in the result names missing metadata;
|
||||
* a caller can load those images and plan again. Report counts retain the import
|
||||
* UI's semantics: a partial polygon can be both drawn and reported unsupported.
|
||||
* Each primitive retains its source record for diagnostics.
|
||||
*/
|
||||
export function planImport(records, limits = defaults, context = {}) {
|
||||
limits = { ...defaults, ...limits };
|
||||
const ground = [];
|
||||
const lines = [];
|
||||
const pins = [];
|
||||
const overlays = [];
|
||||
const meshes = [];
|
||||
const undrawn = [];
|
||||
const images = new Set();
|
||||
const kinds = { ...records.undrawn };
|
||||
let drawn = 0;
|
||||
const skip = (record, reason, counted = true) => {
|
||||
undrawn.push({ record, reason });
|
||||
if (counted) kinds[reason] = (kinds[reason] ?? 0) + 1;
|
||||
};
|
||||
for (const [reason, count] of Object.entries(records.undrawn))
|
||||
undrawn.push({ reason, count });
|
||||
const needImage = (url) => {
|
||||
if (!context.images?.has(url)) images.add(url);
|
||||
return context.images?.get(url);
|
||||
};
|
||||
for (const polygon of records.polygons) {
|
||||
let any = false;
|
||||
if (polygon.raised && polygon.rings.length > limits.MAX_RAISED_RINGS) {
|
||||
skip(polygon, 'complexPolygon');
|
||||
continue;
|
||||
}
|
||||
// Cesium discards short holes; an invalid outer ring cannot become a hole's fill.
|
||||
const rings = [
|
||||
polygon.rings[0] ?? [],
|
||||
...polygon.rings.slice(1).filter(hasRingVertices),
|
||||
];
|
||||
if (polygon.fill && polygon.raised) {
|
||||
// The closing copy is not a vertex in the triangulation budget.
|
||||
const vertices = polygon.rings.reduce(
|
||||
(n, ring) =>
|
||||
n +
|
||||
ring.length -
|
||||
Number(
|
||||
ring.length > 1 &&
|
||||
ring[0][0] === ring.at(-1)[0] &&
|
||||
ring[0][1] === ring.at(-1)[1],
|
||||
),
|
||||
0,
|
||||
);
|
||||
const shape =
|
||||
vertices <= limits.MAX_RAISED_VERTICES &&
|
||||
hasRingVertices(rings[0]) &&
|
||||
polygonShape(rings);
|
||||
if (vertices > limits.MAX_RAISED_VERTICES)
|
||||
skip(polygon, 'complexPolygon');
|
||||
if (shape) {
|
||||
meshes.push({
|
||||
record: polygon,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
color: polygon.fill,
|
||||
});
|
||||
any = true;
|
||||
} else if (vertices <= limits.MAX_RAISED_VERTICES)
|
||||
skip(polygon, 'polygon', false);
|
||||
} else if (polygon.fill) {
|
||||
const item =
|
||||
hasRingVertices(rings[0]) &&
|
||||
checked('polygon', { rings: rings.map(flat), fill: polygon.fill });
|
||||
if (item) {
|
||||
ground.push({
|
||||
record: polygon,
|
||||
kind: 'polygon',
|
||||
order: limits.POLYGON_ORDER,
|
||||
item,
|
||||
});
|
||||
any = true;
|
||||
} else skip(polygon, 'polygon');
|
||||
}
|
||||
if (polygon.raised && polygon.outline)
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 2) continue;
|
||||
lines.push({
|
||||
record: polygon,
|
||||
positions: [...ring, ring[0]].map(cartesian),
|
||||
widthPx: 1,
|
||||
color: polygon.outline.color,
|
||||
});
|
||||
any = true;
|
||||
}
|
||||
if (any) drawn++;
|
||||
else if (!polygon.fill) skip(polygon, 'noFill', false);
|
||||
}
|
||||
const overlaySide = Math.min(
|
||||
limits.MAX_OVERLAY_SIDE,
|
||||
Math.floor(
|
||||
Math.sqrt(
|
||||
limits.OVERLAY_PIXEL_BUDGET / Math.max(1, records.overlays.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Stable sort preserves the document's order for equal KML drawOrder values.
|
||||
for (const overlay of [...records.overlays].sort(
|
||||
(a, b) => (a.drawOrder ?? 0) - (b.drawOrder ?? 0),
|
||||
)) {
|
||||
const raise =
|
||||
overlay.altitudeMode === 'absolute' && overlay.altitude !== 0
|
||||
? overlay.altitude
|
||||
: undefined;
|
||||
if (overlay.url) {
|
||||
const prepared = overlayCanvas(
|
||||
overlay,
|
||||
overlaySide,
|
||||
context.images?.get(overlay.url),
|
||||
);
|
||||
if (!prepared) {
|
||||
skip(overlay, 'rotatedOverlay');
|
||||
continue;
|
||||
}
|
||||
if (prepared.canvas || raise) needImage(overlay.url);
|
||||
if (raise && context.images?.get(overlay.url) === null) {
|
||||
skip(overlay, 'raisedOverlay');
|
||||
continue;
|
||||
}
|
||||
const alpha = Math.min(0.99, overlay.color[3]);
|
||||
const entry = {
|
||||
record: overlay,
|
||||
url: overlay.url,
|
||||
...prepared,
|
||||
alpha,
|
||||
order: limits.OVERLAY_ORDER,
|
||||
};
|
||||
if (raise) {
|
||||
const { west, south, east, north } = prepared.rectangle;
|
||||
const shape = rectangleShape([west, south, east, north], raise);
|
||||
meshes.push({
|
||||
...entry,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
uv: shape.attributes.st.values,
|
||||
color: [1, 1, 1, alpha],
|
||||
});
|
||||
} else overlays.push(entry);
|
||||
drawn++;
|
||||
continue;
|
||||
}
|
||||
if (!checked('cell', overlay.rectangle)) {
|
||||
skip(overlay, 'overlay', false);
|
||||
continue;
|
||||
}
|
||||
const { box, center, turn } = overlayBox(overlay);
|
||||
const rings = [
|
||||
[
|
||||
[box.west, box.south],
|
||||
[box.east, box.south],
|
||||
[box.east, box.north],
|
||||
[box.west, box.north],
|
||||
].map(turn),
|
||||
];
|
||||
const item = checked('polygon', { rings, fill: overlay.color });
|
||||
if (!item) {
|
||||
skip(overlay, 'rotatedOverlay');
|
||||
continue;
|
||||
}
|
||||
if (raise) {
|
||||
const shape = rectangleShape(
|
||||
[box.west, box.south, box.east, box.north],
|
||||
raise,
|
||||
);
|
||||
const positions = shape.attributes.position.values;
|
||||
for (let i = 0; i < positions.length; i += 3) {
|
||||
const at = geo.cartographic.fromCartesian({
|
||||
x: positions[i],
|
||||
y: positions[i + 1],
|
||||
z: positions[i + 2],
|
||||
});
|
||||
const longitude = geo.math.toDegrees(at.longitude);
|
||||
const [lon, lat] = turn([
|
||||
longitude + 360 * Math.round((center[0] - longitude) / 360),
|
||||
geo.math.toDegrees(at.latitude),
|
||||
]);
|
||||
const p = cartesian([lon, lat, raise]);
|
||||
positions.set([p.x, p.y, p.z], i);
|
||||
}
|
||||
meshes.push({
|
||||
record: overlay,
|
||||
positions,
|
||||
indices: shape.indices,
|
||||
color: overlay.color,
|
||||
});
|
||||
} else
|
||||
ground.push({
|
||||
record: overlay,
|
||||
kind: 'polygon',
|
||||
order: limits.POLYGON_ORDER,
|
||||
item,
|
||||
});
|
||||
drawn++;
|
||||
}
|
||||
for (const record of records.lines) {
|
||||
if (record.ground) {
|
||||
const item = checked('corridor', {
|
||||
positions: flat(record.positions),
|
||||
color: record.color,
|
||||
widthPx: record.widthPx,
|
||||
});
|
||||
if (!item) {
|
||||
skip(record, 'line', false);
|
||||
continue;
|
||||
}
|
||||
ground.push({ record, kind: 'corridor', order: limits.LINE_ORDER, item });
|
||||
} else
|
||||
lines.push({
|
||||
record,
|
||||
positions: record.positions.map(cartesian),
|
||||
widthPx: record.widthPx,
|
||||
color: record.color,
|
||||
});
|
||||
drawn++;
|
||||
}
|
||||
const looks = new Map();
|
||||
for (const point of records.points) {
|
||||
const depth = point.altitude === 'absolute' ? 'normal' : 'always';
|
||||
const key = lookKey(point.icon, depth);
|
||||
let look = looks.get(key);
|
||||
if (!look) looks.set(key, (look = { icon: point.icon, depth, pins: [] }));
|
||||
const [lon, lat, height = 0] = point.position;
|
||||
look.pins.push({
|
||||
record: point,
|
||||
lon,
|
||||
lat,
|
||||
offset: point.altitude === 'relative' ? height : 0,
|
||||
height: point.altitude === 'absolute' ? height : null,
|
||||
ground: undefined,
|
||||
position: null,
|
||||
horizon: null,
|
||||
show: true,
|
||||
color: point.icon.color ?? [1, 1, 1, 1],
|
||||
rotation: point.icon.rotation ?? 0,
|
||||
});
|
||||
}
|
||||
const maxSide = Math.min(
|
||||
limits.MAX_ICON_SIDE,
|
||||
Math.floor(Math.sqrt(limits.ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
|
||||
);
|
||||
for (const look of looks.values()) {
|
||||
const url = look.icon.image?.url;
|
||||
if (url && needImage(url) === null) {
|
||||
for (const pin of look.pins) skip(pin.record, 'icon', false);
|
||||
}
|
||||
const draw = !url || context.images?.get(url) !== null;
|
||||
pins.push({ ...look, maxSide, draw });
|
||||
if (draw) drawn += look.pins.length;
|
||||
}
|
||||
return {
|
||||
ground,
|
||||
lines,
|
||||
pins,
|
||||
overlays,
|
||||
meshes,
|
||||
undrawn,
|
||||
images: [...images],
|
||||
report: {
|
||||
drawn,
|
||||
undrawn: Object.values(kinds).reduce((n, c) => n + c, 0),
|
||||
kinds,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { planImport } from './geoImportPlan.js';
|
||||
import { emptyRecords } from './geoImportRecords.js';
|
||||
|
||||
const random = (seed) => () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return seed / 2 ** 32;
|
||||
};
|
||||
const ringArea = (ring) =>
|
||||
Math.abs(
|
||||
ring.reduce((sum, p, i) => {
|
||||
const q = ring[(i + 1) % ring.length];
|
||||
return sum + p[0] * q[1] - q[0] * p[1];
|
||||
}, 0),
|
||||
) / 2;
|
||||
function triangulated(rings, center) {
|
||||
const records = emptyRecords();
|
||||
records.polygons.push({
|
||||
rings,
|
||||
raised: true,
|
||||
fill: [1, 0, 0, 1],
|
||||
outline: null,
|
||||
});
|
||||
const plan = planImport(records);
|
||||
assert.equal(plan.report.drawn, 1);
|
||||
assert.equal(plan.meshes.length, 1);
|
||||
const { positions, indices } = plan.meshes[0];
|
||||
const points = [];
|
||||
for (let i = 0; i < positions.length; i += 3) {
|
||||
const p = geo.cartographic.fromCartesian({
|
||||
x: positions[i],
|
||||
y: positions[i + 1],
|
||||
z: positions[i + 2],
|
||||
});
|
||||
const lon = geo.math.toDegrees(p.longitude);
|
||||
const unwrapped = lon - 360 * Math.round((lon - center[0]) / 360);
|
||||
points.push([
|
||||
unwrapped - center[0],
|
||||
geo.math.toDegrees(p.latitude) - center[1],
|
||||
]);
|
||||
}
|
||||
const local = rings.flat().map(([x, y]) => [x - center[0], y - center[1]]);
|
||||
const min = [0, 1].map((axis) => Math.min(...local.map((p) => p[axis])));
|
||||
const max = [0, 1].map((axis) => Math.max(...local.map((p) => p[axis])));
|
||||
let area = 0;
|
||||
for (let i = 0; i < indices.length; i += 3) {
|
||||
const triangle = Array.from(
|
||||
indices.slice(i, i + 3),
|
||||
(index) => points[index],
|
||||
);
|
||||
for (const p of triangle)
|
||||
for (let axis = 0; axis < 2; axis++)
|
||||
assert.ok(
|
||||
p[axis] >= min[axis] - 1e-9 && p[axis] <= max[axis] + 1e-9,
|
||||
'triangle stays inside ring bounds',
|
||||
);
|
||||
area += ringArea(triangle);
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
for (let seed = 1; seed <= 128; seed++) {
|
||||
test(`raised polygon area, bounds, closure and winding (seed ${seed})`, () => {
|
||||
const next = random(seed);
|
||||
const center = [
|
||||
seed % 8 === 0 ? 179.99 : next() * 350 - 175,
|
||||
next() * 170 - 85,
|
||||
];
|
||||
const radius = 0.01 + next() * 0.1;
|
||||
const height = next() * 5000 - 200;
|
||||
const count = 6 + Math.floor(next() * 40);
|
||||
// Ordered radial vertices make simple concave outlines. Small, disjoint
|
||||
// holes stay inside their minimum radius even near a pole or the dateline.
|
||||
const outer = Array.from({ length: count }, (_, i) => {
|
||||
const angle = (2 * Math.PI * i) / count;
|
||||
const r = radius * (0.7 + next() * 0.3);
|
||||
return [
|
||||
center[0] + Math.cos(angle) * r,
|
||||
center[1] + Math.sin(angle) * r,
|
||||
height,
|
||||
];
|
||||
});
|
||||
const holes = Array.from({ length: seed % 4 }, (_, h) => {
|
||||
const x = center[0] + (h - 1) * radius * 0.3;
|
||||
return Array.from({ length: 5 + h }, (_, i) => {
|
||||
const angle = (2 * Math.PI * i) / (5 + h);
|
||||
return [
|
||||
x + Math.cos(angle) * radius * 0.08,
|
||||
center[1] + Math.sin(angle) * radius * 0.08,
|
||||
height,
|
||||
];
|
||||
});
|
||||
});
|
||||
const rings = [outer, ...holes];
|
||||
const areas = rings.map((ring) =>
|
||||
ringArea(ring.map(([x, y]) => [x - center[0], y - center[1]])),
|
||||
);
|
||||
const expected = areas[0] - areas.slice(1).reduce((sum, n) => sum + n, 0);
|
||||
const actual = triangulated(rings, center);
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) / expected < 0.01,
|
||||
`area ${actual} matches ${expected}`,
|
||||
);
|
||||
for (const variant of [
|
||||
rings.map((r) => [...r, r[0]]),
|
||||
rings.map((r) => [...r].reverse()),
|
||||
[outer, ...holes.map((r) => [...r].reverse())],
|
||||
]) {
|
||||
const changed = triangulated(variant, center);
|
||||
assert.ok(
|
||||
Math.abs(changed - actual) / actual < 1e-8,
|
||||
'closure and winding preserve area',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planImport } from './geoImportPlan.js';
|
||||
import { emptyRecords } from './geoImportRecords.js';
|
||||
import * as limits from './geoImportLimits.js';
|
||||
|
||||
const ring = [
|
||||
[0, 0, 100],
|
||||
[1, 0, 100],
|
||||
[1, 1, 100],
|
||||
[0, 1, 100],
|
||||
];
|
||||
const polygon = (options = {}) => ({
|
||||
rings: [ring],
|
||||
fill: [1, 0, 0, 1],
|
||||
outline: { color: [0, 1, 0, 1], widthPx: 5 },
|
||||
raised: true,
|
||||
...options,
|
||||
});
|
||||
const overlay = (options = {}) => ({
|
||||
url: 'local-image',
|
||||
color: [1, 1, 1, 1],
|
||||
rectangle: { west: 0, south: 0, east: 2, north: 1 },
|
||||
rotation: 0,
|
||||
drawOrder: 0,
|
||||
...options,
|
||||
});
|
||||
|
||||
for (const [name, record, ceilings, counts] of [
|
||||
[
|
||||
'ground fill without outline',
|
||||
polygon({ raised: false }),
|
||||
{},
|
||||
[1, 0, 0, 1, 0],
|
||||
],
|
||||
['raised fill and outline', polygon(), {}, [0, 1, 1, 1, 0]],
|
||||
[
|
||||
'open ring at vertex limit',
|
||||
polygon(),
|
||||
{ MAX_RAISED_VERTICES: ring.length },
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
[
|
||||
'closed ring at vertex limit',
|
||||
polygon({ rings: [[...ring, ring[0]]] }),
|
||||
{ MAX_RAISED_VERTICES: ring.length },
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
['outline only', polygon({ fill: null }), {}, [0, 1, 0, 1, 0]],
|
||||
[
|
||||
'vertex limit keeps outline',
|
||||
polygon(),
|
||||
{ MAX_RAISED_VERTICES: 3 },
|
||||
[0, 1, 0, 1, 1],
|
||||
],
|
||||
[
|
||||
'ring limit skips polygon',
|
||||
polygon(),
|
||||
{ MAX_RAISED_RINGS: 0 },
|
||||
[0, 0, 0, 0, 1],
|
||||
],
|
||||
[
|
||||
'short hole is skipped',
|
||||
polygon({
|
||||
rings: [
|
||||
ring,
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.3, 0.2],
|
||||
],
|
||||
],
|
||||
}),
|
||||
{},
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
[
|
||||
'short outer never promotes hole',
|
||||
polygon({
|
||||
rings: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
ring,
|
||||
],
|
||||
outline: null,
|
||||
}),
|
||||
{},
|
||||
[0, 0, 0, 0, 0],
|
||||
],
|
||||
])
|
||||
test(name, () => {
|
||||
const records = { ...emptyRecords(), polygons: [record] };
|
||||
const before = structuredClone(records);
|
||||
const plan = planImport(records, ceilings);
|
||||
assert.deepEqual(
|
||||
[
|
||||
plan.ground.length,
|
||||
plan.lines.length,
|
||||
plan.meshes.length,
|
||||
plan.report.drawn,
|
||||
plan.report.undrawn,
|
||||
],
|
||||
counts,
|
||||
);
|
||||
assert.deepEqual(records, before);
|
||||
if (plan.lines.length) assert.equal(plan.lines[0].widthPx, 1);
|
||||
});
|
||||
|
||||
for (const [name, record, drawable] of [
|
||||
['unturned image', overlay(), true],
|
||||
['full turn', overlay({ rotation: 360 }), true],
|
||||
['turn past 360', overlay({ rotation: 405 }), true],
|
||||
[
|
||||
'across antimeridian',
|
||||
overlay({
|
||||
rectangle: { west: 179, south: -1, east: -179, north: 1 },
|
||||
rotation: 30,
|
||||
}),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'near pole',
|
||||
overlay({
|
||||
rectangle: { west: 0, south: 85, east: 2, north: 86 },
|
||||
rotation: 30,
|
||||
}),
|
||||
true,
|
||||
],
|
||||
[
|
||||
'past pole',
|
||||
overlay({
|
||||
rectangle: { west: 0, south: 88, east: 20, north: 90 },
|
||||
rotation: 45,
|
||||
}),
|
||||
false,
|
||||
],
|
||||
[
|
||||
'all longitudes tinted',
|
||||
overlay({
|
||||
rectangle: { west: -180, south: -1, east: 180, north: 1 },
|
||||
color: [0.5, 1, 1, 1],
|
||||
}),
|
||||
true,
|
||||
],
|
||||
])
|
||||
test(name, () => {
|
||||
const plan = planImport({ ...emptyRecords(), overlays: [record] });
|
||||
assert.equal(plan.overlays.length, Number(drawable));
|
||||
assert.equal(plan.report.undrawn, Number(!drawable));
|
||||
if (drawable) assert.equal(plan.overlays[0].order, limits.OVERLAY_ORDER);
|
||||
});
|
||||
|
||||
test('altitudes, look grouping and budgets are planned without decoding images', () => {
|
||||
const icon = { image: { url: 'pin' }, color: [1, 1, 1, 1] };
|
||||
const records = {
|
||||
...emptyRecords(),
|
||||
points: ['ground', 'relative', 'absolute'].map((altitude) => ({
|
||||
position: [2, 3, 40],
|
||||
altitude,
|
||||
icon,
|
||||
})),
|
||||
overlays: Array.from({ length: 9 }, (_, i) =>
|
||||
overlay({
|
||||
drawOrder: 9 - i,
|
||||
rotation: 30,
|
||||
color: [i / 9, 1, 1, 0.8],
|
||||
}),
|
||||
),
|
||||
};
|
||||
const context = {
|
||||
images: new Map([
|
||||
['pin', { width: 1000, height: 1000 }],
|
||||
['local-image', { width: 8192, height: 8192 }],
|
||||
]),
|
||||
};
|
||||
const plan = planImport(records, undefined, context);
|
||||
assert.deepEqual(
|
||||
plan.pins.map((p) => p.depth),
|
||||
['always', 'normal'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
plan.pins.flatMap((p) => p.pins.map((pin) => [pin.height, pin.offset])),
|
||||
[
|
||||
[null, 0],
|
||||
[null, 40],
|
||||
[40, 0],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
plan.overlays.map((o) => o.record.drawOrder),
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
);
|
||||
assert.ok(
|
||||
plan.overlays.reduce(
|
||||
(sum, o) => sum + o.canvas.width * o.canvas.height,
|
||||
0,
|
||||
) <= limits.OVERLAY_PIXEL_BUDGET,
|
||||
);
|
||||
assert.ok(plan.pins.every((look) => look.maxSide <= limits.MAX_ICON_SIDE));
|
||||
assert.equal(plan.report.drawn, 12);
|
||||
assert.deepEqual(plan.images, []);
|
||||
});
|
||||
|
||||
test('decode failures retain the untransformed layer and report unsupported raised images', () => {
|
||||
const records = {
|
||||
...emptyRecords(),
|
||||
points: [
|
||||
{
|
||||
position: [0, 0],
|
||||
altitude: 'ground',
|
||||
icon: { image: { url: 'missing' } },
|
||||
},
|
||||
],
|
||||
overlays: [
|
||||
overlay({ url: 'missing', rotation: 30 }),
|
||||
overlay({ url: 'missing', altitudeMode: 'absolute', altitude: 100 }),
|
||||
],
|
||||
};
|
||||
const plan = planImport(records, undefined, {
|
||||
images: new Map([['missing', null]]),
|
||||
});
|
||||
assert.equal(plan.pins[0].draw, false);
|
||||
assert.equal(plan.overlays[0].canvas, null);
|
||||
assert.deepEqual(plan.overlays[0].rectangle, records.overlays[0].rectangle);
|
||||
assert.deepEqual(plan.report, {
|
||||
drawn: 1,
|
||||
undrawn: 1,
|
||||
kinds: { raisedOverlay: 1 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
plan.undrawn.map((item) => item.reason),
|
||||
['raisedOverlay', 'icon'],
|
||||
);
|
||||
});
|
||||
|
||||
for (const [altitudeMode, altitude, url, expectedHeight] of [
|
||||
['absolute', -100, 'local-image', -100],
|
||||
['absolute', 200, 'local-image', 200],
|
||||
['relative', 200, 'local-image', null],
|
||||
['ground', 200, 'local-image', null],
|
||||
['absolute', 200, null, 200],
|
||||
])
|
||||
test(`overlay altitude ${altitudeMode} ${altitude}, image ${Boolean(url)}`, async () => {
|
||||
const { cartographic } = await import('../geo/index.js');
|
||||
const record = overlay({ altitudeMode, altitude, url, rotation: 30 });
|
||||
const plan = planImport({ ...emptyRecords(), overlays: [record] });
|
||||
assert.equal(plan.report.drawn, 1);
|
||||
assert.equal(plan.report.undrawn, 0);
|
||||
assert.equal(plan.meshes.length, Number(expectedHeight !== null));
|
||||
if (expectedHeight !== null) {
|
||||
const positions = plan.meshes[0].positions;
|
||||
for (let i = 0; i < positions.length; i += 3) {
|
||||
const height = cartographic.fromCartesian({
|
||||
x: positions[i],
|
||||
y: positions[i + 1],
|
||||
z: positions[i + 2],
|
||||
}).height;
|
||||
assert.ok(Math.abs(height - expectedHeight) < 0.001);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('equal overlay orders retain document order', () => {
|
||||
const a = overlay({ name: 'a', drawOrder: 3 });
|
||||
const b = overlay({ name: 'b', drawOrder: -1 });
|
||||
const c = overlay({ name: 'c', drawOrder: 3 });
|
||||
const plan = planImport({ ...emptyRecords(), overlays: [a, b, c] });
|
||||
assert.deepEqual(
|
||||
plan.overlays.map((item) => item.record.name),
|
||||
['b', 'a', 'c'],
|
||||
);
|
||||
});
|
||||
@@ -69,8 +69,7 @@
|
||||
// absolute overlay to it.
|
||||
// @property {'ground'|'relative'|'absolute'} altitudeMode
|
||||
|
||||
/** Longest plain-text name kept from a file's features. */
|
||||
const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
import { MAX_IMPORT_NAME_LENGTH } from './geoImportLimits.js';
|
||||
|
||||
/** A feature name as short plain text (no control characters), or undefined. */
|
||||
export function importFeatureName(value) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/** Session-local file controls, serial loading and application-owned navigation. */
|
||||
import {
|
||||
GeoImportError,
|
||||
MAX_IMPORTED_FILES,
|
||||
loadGeoFile,
|
||||
} from '../data/geoFileImport.js';
|
||||
import { GeoImportError, loadGeoFile } from '../data/geoFileImport.js';
|
||||
import { MAX_IMPORTED_FILES } from '../data/geoImportLimits.js';
|
||||
import { drawImportedFile } from '../data/geoImportDisplay.js';
|
||||
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { flyToFootprint } from './flyToDataSource.js';
|
||||
|
||||
@@ -9,7 +9,8 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createGeoFileImport, summarize } from './geoFileImport.js';
|
||||
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
|
||||
import { GeoImportError } from '../data/geoFileImport.js';
|
||||
import { MAX_IMPORTED_FILES } from '../data/geoImportLimits.js';
|
||||
import { emptyRecords, recordFootprint } from '../data/geoImportRecords.js';
|
||||
|
||||
class FakeClassList {
|
||||
|
||||
Reference in new issue
Block a user