Merge branch 'german/refactor-e9-import-plan' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:27:12 +04:00
commit 49b180f1fd
32 files changed
+103263 -458

No files matched your search

+3 -1
View File
@@ -6703,8 +6703,9 @@ request viewbox and continues searching without that geographic preference.
### Import Your Own Files (October 2026) ### 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. - 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. - **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`. - **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. - **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. - **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. - **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. - **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. - **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) ### Recent Imagery (September 2026)
+7 -6
View File
@@ -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 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 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, 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 and an absolute image or plain-colour overlay is a mesh at its altitude,
absolute ones). The shared display has known limits against Cesium: wall including negative heights (Cesium raises only absolute ones).
fills and extruded prisms are omitted (their top geometry remains), and The shared display has known limits against Cesium: wall fills and extruded
`gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised prisms are omitted (their top geometry remains), and
plain-colour overlay are omitted. These are counted as undrawn. Ground edges `gx:LatLonQuad` and a rotated overlay whose box passes a pole are omitted.
use great circles rather than rhumb lines. These are counted as undrawn. Ground edges use great circles rather than
rhumb lines.
## Input ownership ## Input ownership
+45
View File
@@ -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.
+11
View File
@@ -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>
+9
View File
@@ -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]]]}}]}
+8
View File
@@ -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.
+7
View File
@@ -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
+9
View File
@@ -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>
+10
View File
@@ -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>
+5
View File
@@ -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>
+7 -3
View File
@@ -3590,7 +3590,9 @@
"src/data/imagingSensors.js", "src/data/imagingSensors.js",
"src/data/planespottersPhoto.js", "src/data/planespottersPhoto.js",
"src/globe/cesium/orbital.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": [ "external": [
"hls.js", "hls.js",
@@ -4302,7 +4304,8 @@
"src/layers/satellites/sensors.js", "src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js", "src/data/ucsSatellites.js",
"src/data/imagingSensors.js", "src/data/imagingSensors.js",
"src/data/planespottersPhoto.js" "src/data/planespottersPhoto.js",
"src/data/geoImportLimits.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -5254,7 +5257,8 @@
"src/data/geoImportImages.js", "src/data/geoImportImages.js",
"src/data/geoImportRecords.js", "src/data/geoImportRecords.js",
"src/data/geoImportKml.js", "src/data/geoImportKml.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/data/geoImportLimits.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
+518
View File
@@ -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
View File
@@ -8,20 +8,19 @@ import {
} from './geoImportRecords.js'; } from './geoImportRecords.js';
import { msg } from '../i18n/marker.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 }; 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. */ /** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
@@ -122,7 +121,9 @@ export function importDisplayName(name) {
.replace(/[\u0000-\u001f\u007f]/g, '') .replace(/[\u0000-\u001f\u007f]/g, '')
.trim(); .trim();
if (!clean) return 'Imported file'; 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) || !c.every(Number.isFinite) ||
Math.abs(c[0]) > 180 || Math.abs(c[0]) > 180 ||
Math.abs(c[1]) > 90 || 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( throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'), msg('That GeoJSON has invalid coordinates.'),
@@ -800,7 +801,7 @@ export function countKmlGeometry(
const limit = ['north', 'south'].includes(name) const limit = ['north', 'south'].includes(name)
? 90 ? 90
: name === 'altitude' : name === 'altitude'
? 1_000_000 ? MAX_IMPORT_HEIGHT_M
: 360; : 360;
if ( if (
!el.textContent.trim() || !el.textContent.trim() ||
@@ -863,7 +864,7 @@ function checkKmlPosition(tuple) {
!numbers.every(Number.isFinite) || !numbers.every(Number.isFinite) ||
Math.abs(numbers[0]) > 180 || Math.abs(numbers[0]) > 180 ||
Math.abs(numbers[1]) > 90 || 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.')); throw new GeoImportError(msg('That KML has invalid coordinates.'));
} }
@@ -1123,7 +1124,7 @@ async function boundedImport(promise, signal) {
timer = setTimeout( timer = setTimeout(
() => () =>
reject(new GeoImportError(msg('That file took too long to read.'))), 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 } = {}) { export async function loadGeoFile(file, { color = '#39d0ff', signal } = {}) {
signal = signal signal = signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) ? AbortSignal.any([signal, AbortSignal.timeout(IMPORT_TIMEOUT_MS)])
: AbortSignal.timeout(60_000); : AbortSignal.timeout(IMPORT_TIMEOUT_MS);
signal?.throwIfAborted(); signal?.throwIfAborted();
if (!file || !Number.isFinite(file.size)) if (!file || !Number.isFinite(file.size))
throw new GeoImportError(msg('Nothing to import.')); throw new GeoImportError(msg('Nothing to import.'));
+11 -6
View File
@@ -6,8 +6,6 @@ import {
GeoImportError, GeoImportError,
repairKmlRootNamespaces, repairKmlRootNamespaces,
loadGeoFile, loadGeoFile,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
countGeoJsonGeometry, countGeoJsonGeometry,
countKmlGeometry, countKmlGeometry,
deduplicateKmlStyles, deduplicateKmlStyles,
@@ -23,6 +21,12 @@ import {
readZipEntries, readZipEntries,
resolveKmzPath, resolveKmzPath,
} from './geoFileImport.js'; } 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'; import { createImportImageBudget, importImageSize } from './geoImportImages.js';
test('real loader grounds XYZ GeoJSON and keeps only names and footprints in its records', async () => { 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); v.setUint32(20, h);
return b; 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(); const charge = createImportImageBudget();
charge('shared', png(4096, 4096)); charge('shared', png(side, side));
charge('shared', png(4096, 4096)); charge('shared', png(side, side));
assert.throws(() => charge('other', png(1, 1)), /limit/); assert.throws(() => charge('other', png(1, 1)), /limit/);
assert.throws( assert.throws(
() => createImportImageBudget()('huge', png(16384, 16384)), () => createImportImageBudget()('huge', png(MAX_IMPORT_IMAGE_SIDE + 1, 1)),
/limit/, /limit/,
); );
}); });
+146 -396
View File
@@ -11,7 +11,7 @@
// ignored the depth test; // ignored the depth test;
// - ground overlays: image layers, or textured meshes at absolute altitudes, // - 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 // 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 // Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround(): // the points come from engine.surface, sampled again by refreshGround():
// the terrain where the globe is drawn, else (3D tiles) the scene drawn // the terrain where the globe is drawn, else (3D tiles) the scene drawn
@@ -19,35 +19,17 @@
// pins out of height queries). // pins out of height queries).
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js'; import { planImport } from './geoImportPlan.js';
import { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.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 { 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; let serial = 0;
// Each engine's imported point sets, left out of each other's ground. // Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap(); const pointSets = new WeakMap();
@@ -64,105 +46,27 @@ const imageFromUrl = async (url) => {
return image; return image;
}; };
// A ground item the contract accepts, or null (a degenerate ring or line). // Execute the canvas recipe; geometry and pixel budgets are decided by the plan.
function checked(kind, item) { function overlayImage(entry, images, createCanvas, cache) {
try { const recipe = entry.canvas;
normalizeGroundItem(kind, item); if (!recipe) return images.get(entry.url);
return item; if (cache.has(recipe.key)) return cache.get(recipe.key);
} 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];
const canvas = createCanvas(); const canvas = createCanvas();
canvas.width = Math.max(1, Math.round(spanX * scaleX)); canvas.width = recipe.width;
canvas.height = Math.max(1, Math.round(spanY * scaleY)); canvas.height = recipe.height;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
context.translate(canvas.width / 2, canvas.height / 2); context.translate(canvas.width / 2, canvas.height / 2);
// The canvas's y runs south: a counter-clockwise turn is a negative one. context.rotate(-recipe.angle);
context.rotate(-angle); context.drawImage(images.get(entry.url), ...recipe.draw);
context.drawImage( if (recipe.tint.some((value) => value !== 1)) {
image,
(-width * scaleX) / 2,
(-height * scaleY) / 2,
width * scaleX,
height * scaleY,
);
if (tint.some((value) => value !== 1)) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height); const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < pixels.data.length; i += 4) for (let i = 0; i < pixels.data.length; i += 4)
for (let channel = 0; channel < 3; channel++) 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); context.putImageData(pixels, 0, 0);
} }
cache.set(key, canvas); cache.set(recipe.key, canvas);
return { image: canvas, rectangle: bounds }; return canvas;
} }
// KML headings are from north (Cesium's billboard on the Earth's axis): // 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 turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation);
const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0); 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 * Draw one imported file's records on `engine`. Resolves once its icons
* are decoded and every set is committed. * are decoded and every set is committed.
@@ -212,7 +98,10 @@ export async function drawImportedFile(
visible = true, visible = true,
loadImage = imageFromUrl, loadImage = imageFromUrl,
createCanvas = () => document.createElement('canvas'), 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(), now = () => performance.now(),
signal, signal,
isCurrent = () => true, isCurrent = () => true,
@@ -227,7 +116,7 @@ export async function drawImportedFile(
const lines = []; const lines = [];
const meshes = []; const meshes = [];
const pins = []; const pins = [];
const kinds = { ...records.undrawn }; const kinds = {};
const layers = []; const layers = [];
const looks = new Map(); const looks = new Map();
let drawn = 0; let drawn = 0;
@@ -256,276 +145,137 @@ export async function drawImportedFile(
try { try {
checkActive(); checkActive();
// Fills under ground lines; a plain-colour overlay is a fill too. const ground = [];
const fills = []; 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(); let lastYield = now();
for (const polygon of records.polygons) { for (const polygon of records.polygons) {
if (now() - lastYield > 8) { if (now() - lastYield > POLYGON_YIELD_MS) {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
checkActive(); checkActive();
lastYield = now(); lastYield = now();
} }
let any = false; drawPlan(planImport({ ...emptyRecords(), polygons: [polygon] }));
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++;
} }
const overlayCache = new Map(); const remaining = { ...records, polygons: [] };
const overlayImages = new Map(); const pending = planImport(remaining);
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))),
);
await Promise.all( await Promise.all(
[...looks.values()].map(async (look) => { pending.images.map(async (url) => {
const url = look.icon.image?.url; try {
let image; const image = await loadImage(url);
if (url) { images.set(url, image);
try { metadata.set(url, {
image = await loadImage(url); width: image.naturalWidth || image.width,
} catch { height: image.naturalHeight || image.height,
// An icon that does not decode draws nothing, as a billboard });
// without an image. } catch {
return; metadata.set(url, null);
}
} }
checkActive(); 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(); 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) { for (let from = 0; from < pins.length; from += BATCH) {
settle(heightContext(), from, from + BATCH); settle(heightContext(), from, from + BATCH);
if (from + BATCH < pins.length) { if (from + BATCH < pins.length) {
+26 -15
View File
@@ -3,6 +3,12 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; 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 { drawImportedFile } from './geoImportDisplay.js';
import { emptyRecords, geoJsonRecords } from './geoImportRecords.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( displayTest(
`a raised polygon with ${rings} rings respects the ring bound`, `a raised polygon with ${rings} rings respects the ring bound`,
async ({ engine }) => { async ({ engine }) => {
@@ -469,12 +475,18 @@ for (const make of GROUND_HARNESSES) {
}); });
assert.deepEqual( assert.deepEqual(
drawing.report, drawing.report,
rings === 128 rings === MAX_RAISED_RINGS
? { drawn: 1, undrawn: 0, kinds: {} } ? { drawn: 1, undrawn: 0, kinds: {} }
: { drawn: 0, undrawn: 1, kinds: { complexPolygon: 1 } }, : { drawn: 0, undrawn: 1, kinds: { complexPolygon: 1 } },
); );
assert.equal(engine.meshes.list().length, rings === 128 ? 1 : 0); assert.equal(
assert.equal(engine.lines.list().length, rings === 128 ? rings : 0); engine.meshes.list().length,
rings === MAX_RAISED_RINGS ? 1 : 0,
);
assert.equal(
engine.lines.list().length,
rings === MAX_RAISED_RINGS ? rings : 0,
);
drawing.destroy(); drawing.destroy();
}, },
); );
@@ -519,11 +531,11 @@ for (const make of GROUND_HARNESSES) {
); );
displayTest( 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 }) => { async ({ engine }) => {
const records = emptyRecords(); const records = emptyRecords();
const outer = Array.from({ length: 40_000 }, (_, i) => { const outer = Array.from({ length: MAX_RAISED_VERTICES + 1 }, (_, i) => {
const angle = (i * 2 * Math.PI) / 40_000; const angle = (i * 2 * Math.PI) / (MAX_RAISED_VERTICES + 1);
return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000]; return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000];
}); });
records.polygons.push({ records.polygons.push({
@@ -684,7 +696,7 @@ for (const make of GROUND_HARNESSES) {
); );
displayTest( displayTest(
'a polygon the ground contract rejects is counted undrawn', 'a ground polygon skips a short hole as Cesium does',
async ({ engine }) => { async ({ engine }) => {
const records = geoJsonRecords( const records = geoJsonRecords(
{ {
@@ -706,9 +718,9 @@ for (const make of GROUND_HARNESSES) {
]); ]);
const drawing = await drawImportedFile(engine, records, { createCanvas }); const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.deepEqual(drawing.report, { assert.deepEqual(drawing.report, {
drawn: 0, drawn: 1,
undrawn: 1, undrawn: 0,
kinds: { polygon: 1 }, kinds: {},
}); });
drawing.destroy(); drawing.destroy();
}, },
@@ -1054,7 +1066,6 @@ for (const make of GROUND_HARNESSES) {
}, },
}; };
}; };
const OVERLAY_PIXELS = 16 * 1024 * 1024;
displayTest( displayTest(
'turned overlays sharing one small image share one bounded canvas', '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(drawing.report.drawn, 20);
assert.equal(counted.canvases.length, 1, 'equal looks share a canvas'); assert.equal(counted.canvases.length, 1, 'equal looks share a canvas');
// Each of 20 overlays may hold a twentieth of the budget. // 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; const [canvas] = counted.canvases;
assert.ok( assert.ok(
canvas.width <= side && canvas.height <= side, canvas.width <= side && canvas.height <= side,
@@ -1104,7 +1115,7 @@ for (const make of GROUND_HARNESSES) {
assert.equal(drawing.report.drawn, 100); assert.equal(drawing.report.drawn, 100);
assert.equal(counted.canvases.length, 100); assert.equal(counted.canvases.length, 100);
assert.ok( assert.ok(
counted.pixels() <= OVERLAY_PIXELS, counted.pixels() <= OVERLAY_PIXEL_BUDGET,
`${Math.round((counted.pixels() * 4) / 2 ** 20)} MiB of canvas RGBA`, `${Math.round((counted.pixels() * 4) / 2 ** 20)} MiB of canvas RGBA`,
); );
drawing.destroy(); drawing.destroy();
@@ -1275,7 +1286,7 @@ for (const make of GROUND_HARNESSES) {
}, 0); }, 0);
await drawing.refreshGround(); await drawing.refreshGround();
clearTimeout(heartbeat); clearTimeout(heartbeat);
assert.equal(beforeYield, 256); assert.equal(beforeYield, BATCH);
assert.equal(clamps, 600); assert.equal(clamps, 600);
drawing.destroy(); drawing.destroy();
}, },
+7 -3
View File
@@ -1,6 +1,8 @@
// Inspect encoded headers before allowing the browser to decode local images. // Inspect encoded headers before allowing the browser to decode local images.
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; import {
const MAX_SIDE = 8192; MAX_IMPORT_IMAGE_PIXELS,
MAX_IMPORT_IMAGE_SIDE,
} from './geoImportLimits.js';
/** Width and height from a supported raster header; no image is decoded. */ /** Width and height from a supported raster header; no image is decoded. */
export function importImageSize(bytes) { export function importImageSize(bytes) {
@@ -62,7 +64,9 @@ export function createImportImageBudget() {
const size = importImageSize(bytes); const size = importImageSize(bytes);
if ( if (
!size || !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 pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
) )
throw new Error( throw new Error(
+54
View File
@@ -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;
+3 -1
View File
@@ -4,6 +4,8 @@
// whose centre is the point (src/data/geoImportDisplay.js), so the pin's // 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. // 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). */ /** Cesium.Color's CSS form of rgba in 0..1 (its float-to-byte conversion). */
export function cssOf([red, green, blue, alpha = 1]) { export function cssOf([red, green, blue, alpha = 1]) {
const byte = (v) => (v === 1 ? 255 : (v * 256) | 0); const byte = (v) => (v === 1 ? 255 : (v * 256) | 0);
@@ -89,7 +91,7 @@ export function iconBox(icon, natural) {
*/ */
export function composeIcon( export function composeIcon(
icon, icon,
{ image, createCanvas, ratio = 1, maxSide = 512 } = {}, { image, createCanvas, ratio = 1, maxSide = MAX_ICON_SIDE } = {},
) { ) {
const natural = image const natural = image
? { ? {
+381
View File
@@ -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,
},
};
}
+118
View File
@@ -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',
);
}
});
}
+273
View File
@@ -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'],
);
});
+1 -2
View File
@@ -69,8 +69,7 @@
// absolute overlay to it. // absolute overlay to it.
// @property {'ground'|'relative'|'absolute'} altitudeMode // @property {'ground'|'relative'|'absolute'} altitudeMode
/** Longest plain-text name kept from a file's features. */ import { MAX_IMPORT_NAME_LENGTH } from './geoImportLimits.js';
const MAX_IMPORT_NAME_LENGTH = 120;
/** A feature name as short plain text (no control characters), or undefined. */ /** A feature name as short plain text (no control characters), or undefined. */
export function importFeatureName(value) { export function importFeatureName(value) {
+2 -5
View File
@@ -1,9 +1,6 @@
/** Session-local file controls, serial loading and application-owned navigation. */ /** Session-local file controls, serial loading and application-owned navigation. */
import { import { GeoImportError, loadGeoFile } from '../data/geoFileImport.js';
GeoImportError, import { MAX_IMPORTED_FILES } from '../data/geoImportLimits.js';
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
import { drawImportedFile } from '../data/geoImportDisplay.js'; import { drawImportedFile } from '../data/geoImportDisplay.js';
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js'; import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
import { flyToFootprint } from './flyToDataSource.js'; import { flyToFootprint } from './flyToDataSource.js';
+2 -1
View File
@@ -9,7 +9,8 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import { createGeoFileImport, summarize } from './geoFileImport.js'; 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'; import { emptyRecords, recordFootprint } from '../data/geoImportRecords.js';
class FakeClassList { class FakeClassList {