39 |
--------------------------------------------------------------------------------
/docs/assets/Cherry Creek 1-Small.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mbucari/GEHistoricalImagery/9bc66114ae58f09e81f04d5c23fdb2bed946981a/docs/assets/Cherry Creek 1-Small.jpg
--------------------------------------------------------------------------------
/docs/assets/Cherry Creek 2-Small.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mbucari/GEHistoricalImagery/9bc66114ae58f09e81f04d5c23fdb2bed946981a/docs/assets/Cherry Creek 2-Small.jpg
--------------------------------------------------------------------------------
/docs/assets/Cherry Creek 3-Small.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mbucari/GEHistoricalImagery/9bc66114ae58f09e81f04d5c23fdb2bed946981a/docs/assets/Cherry Creek 3-Small.jpg
--------------------------------------------------------------------------------
/docs/assets/Cherry Creek 4-Small.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mbucari/GEHistoricalImagery/9bc66114ae58f09e81f04d5c23fdb2bed946981a/docs/assets/Cherry Creek 4-Small.jpg
--------------------------------------------------------------------------------
/docs/availability.md:
--------------------------------------------------------------------------------
1 | # Availability
2 | _Get imagery date availability in a specified region._
3 |
4 | This command shows a diagram of image tile availablity within the specified region.
5 | Tiles that are available from a specific date are shaded, and unavailable tiles are represented with a dot.
6 |
7 | ## Usage
8 | ```Console
9 | GEHistoricalImagery availability --lower-left [LAT,LONG] --upper-right [LAT,LONG] --zoom [N] [--parallel [N]] [--provider [P]] [--no-cache]
10 |
11 | --lower-left=LAT,LONG Required. Geographic coordinate of the lower-left (southwest) corner of the rectangular area
12 | of interest.
13 |
14 | --upper-right=LAT,LONG Required. Geographic coordinate of the upper-right (northeast) corner of the rectangular
15 | area of interest.
16 |
17 | -z N, --zoom=N Required. Zoom level [1-23]
18 |
19 | -p N, --parallel=N (Default: 20) Number of concurrent downloads
20 |
21 | --provider=TM (Default: TM) Aerial imagery provider
22 | [TM] Google Earth Time Machine
23 | [Wayback] ESRI World Imagery Wayback
24 |
25 | --no-cache (Default: false) Disable local caching
26 | ```
27 |
28 | ## Example
29 | Gets the availability diagram for the rectangular region defined by the lower-left (southwest) corner `39.619819,-104.856121` and upper-right (northeast) corner `39.638393,-104.824990`.
30 |
31 | **Command:**
32 | ```console
33 | GEHistoricalImagery availability --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 20
34 | ```
35 | **Output:**
36 | ```Console
37 | Loading Quad Tree Packets: Done!
38 | [0] 2024/06/05 [1] 2023/09/05 [2] 2023/05/28 [3] 2023/04/29 [4] 2022/09/26
39 | [5] 2021/08/17 [6] 2021/06/15 [7] 2021/06/11 [8] 2020/10/03 [9] 2020/09/30
40 | [a] 2020/06/07 [b] 2019/10/03 [c] 2019/09/13 [d] 2018/06/01 [e] 2017/06/10
41 | [f] 2017/05/14 [g] 2015/10/10 [h] 2014/10/07 [i] 2014/06/03 [j] 2013/10/07
42 | [k] 2012/10/08 [l] 2011/05/05 [m] 2010/06/16 [Esc] Exit
43 | ```
44 |
45 | From here you can select different dates to display the imagery availability.
46 |
47 | ### Availability Map 1 - Imagery from 2024/06/05
48 | This diagram, shown by pressing `0` in the console, shows the tiles with available imagery from 2024/06/05. The shaded areas represent tiles which contain imagery for the selected date. The entire region is shaded, so imagery from 2024/06/05 is available for all tiles within the region.
49 |
50 | ```console
51 | Tile availability on 2024/06/05
52 | ===============================
53 |
54 | ████████████████████████████████████████████████████████████████████████████████████████████
55 | ████████████████████████████████████████████████████████████████████████████████████████████
56 | ████████████████████████████████████████████████████████████████████████████████████████████
57 | ████████████████████████████████████████████████████████████████████████████████████████████
58 | ████████████████████████████████████████████████████████████████████████████████████████████
59 | ████████████████████████████████████████████████████████████████████████████████████████████
60 | ████████████████████████████████████████████████████████████████████████████████████████████
61 | ████████████████████████████████████████████████████████████████████████████████████████████
62 | ████████████████████████████████████████████████████████████████████████████████████████████
63 | ████████████████████████████████████████████████████████████████████████████████████████████
64 | ████████████████████████████████████████████████████████████████████████████████████████████
65 | ████████████████████████████████████████████████████████████████████████████████████████████
66 | ████████████████████████████████████████████████████████████████████████████████████████████
67 | ████████████████████████████████████████████████████████████████████████████████████████████
68 | ████████████████████████████████████████████████████████████████████████████████████████████
69 | ████████████████████████████████████████████████████████████████████████████████████████████
70 | ████████████████████████████████████████████████████████████████████████████████████████████
71 | ████████████████████████████████████████████████████████████████████████████████████████████
72 | ████████████████████████████████████████████████████████████████████████████████████████████
73 | ████████████████████████████████████████████████████████████████████████████████████████████
74 | ████████████████████████████████████████████████████████████████████████████████████████████
75 | ████████████████████████████████████████████████████████████████████████████████████████████
76 | ████████████████████████████████████████████████████████████████████████████████████████████
77 | ████████████████████████████████████████████████████████████████████████████████████████████
78 | ████████████████████████████████████████████████████████████████████████████████████████████
79 | ████████████████████████████████████████████████████████████████████████████████████████████
80 | ████████████████████████████████████████████████████████████████████████████████████████████
81 | ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
82 | ```
83 | ### Availability Map 2 - Imagery from 2023/04/29
84 | This diagram, shown by pressing `3` in the console, shows the tiles with available imagery from 2023/04/29. The shaded areas represent tiles which contain imagery for the selected date, and the dots represent tiles which have no imagery for the selected date. The right ~70% of this region is shaded, so only that area has imagery from 2023/04/29.
85 |
86 | ```console
87 | Tile availability on 2023/04/29
88 | ===============================
89 |
90 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
91 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
92 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
93 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
94 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
95 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
96 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
97 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
98 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
99 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
100 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
101 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
102 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
103 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
104 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
105 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
106 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
107 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
108 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
109 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
110 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
111 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
112 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
113 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
114 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
115 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
116 | ::::::::::::::::::::::::::::████████████████████████████████████████████████████████████████
117 | ˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
118 | ```
119 | ### Availability Map 3 - Imagery from 2021/05/17
120 | This diagram, shown by pressing `5` in the console, shows the tiles with available imagery from 2021/08/17. The shaded areas represent tiles which contain imagery for the selected date, and the dots represent tiles which have no imagery for the selected date. Only a narrow L-shaped region is shaded, so the majority of this region has no imagery from 2021/08/17.
121 |
122 | ```console
123 | Tile availability on 2021/05/17
124 | ===============================
125 |
126 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
127 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
128 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
129 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
130 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
131 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
132 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
133 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
134 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
135 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
136 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
137 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
138 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
139 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
140 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
141 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
142 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
143 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
144 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
145 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
146 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
147 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::████::::::::
148 | ████████████████████████████████████████████████████████████████████████████████████::::::::
149 | ████████████████████████████████████████████████████████████████████████████████████::::::::
150 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
151 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
152 | ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
153 | ˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙
154 | ```
155 |
156 | ************************
157 |
Updated 2025/02/19
158 |
--------------------------------------------------------------------------------
/docs/download.md:
--------------------------------------------------------------------------------
1 | # Download
2 | _Download historical imagery._
3 |
4 | This command will download historical imagery from within a region on a specified date and save it as a single GeoTiff file. You may optionally specify an output spatial reference to warp the image.
5 | If imagery is not available for the specified date, the downloader will use the image from the next nearest date.
6 |
7 | ## Usage
8 | ```Console
9 | GEHistoricalImagery download --lower-left [LAT,LONG] --upper-right [LAT,LONG] -z [N] -d [yyyy/mm/dd] -o [PATH] [--target-sr "SPATIAL REFERENCE"]] [-p [N]] [--scale [S]] [--offset-x [X]] [--offset-y [Y]] [--scale-first] [--provider [P]] [--no-cache]
10 |
11 | --lower-left=LAT,LONG Required. Geographic coordinate of the lower-left (southwest) corner of the
12 | rectangular area of interest.
13 |
14 | --upper-right=LAT,LONG Required. Geographic coordinate of the upper-right (northeast) corner of the
15 | rectangular area of interest.
16 |
17 | -z N, --zoom=N Required. Zoom level [1-23]
18 |
19 | -d yyyy/MM/dd, --date=yyyy/MM/dd Required. Imagery Date
20 |
21 | --layer-date (Wayback only) The date specifies a layer instead of an image capture date
22 |
23 | -o out.tif, --output=out.tif Required. Output GeoTiff save location
24 |
25 | -p N, --parallel=N (Default: ALL_CPUS) Number of concurrent downloads
26 |
27 | --target-sr=https://epsg.io/1234.wkt Warp image to Spatial Reference
28 |
29 | --scale=S (Default: 1) Geo transform scale factor
30 |
31 | --offset-x=X (Default: 0) Geo transform X offset
32 |
33 | --offset-y=Y (Default: 0) Geo transform Y offset
34 |
35 | --scale-first (Default: false) Perform scaling before offsetting X and Y
36 |
37 | --provider=TM (Default: TM) Aerial imagery provider
38 | [TM] Google Earth Time Machine
39 | [Wayback] ESRI World Imagery Wayback
40 |
41 | --no-cache (Default: false) Disable local caching
42 | ```
43 |
44 | ## Examples
45 | Download historical imagery at zoom level `20` from within the region defined by the lower-left (southwest) corner `39.619819,-104.856121` and upper-right (northeast) corner `39.638393,-104.824990`. Transform the image to SPCS Colorado Central - Feet.
46 |
47 | ### Example 1 - Get imagery from 2024/06/05
48 |
49 | **Command:**
50 | ```Console
51 | GEHistoricalImagery download --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 20 --date 2024/06/05 --target-sr https://epsg.io/103248.wkt --output "./Cherry Creek 1.tif"
52 | ```
53 | **Output:**
54 | 
55 | [click here to download the original file](../../../raw/d607b9c7f8851316ff893ed02396c95bb55391ef/docs/assets/Cherry%20Creek%201.tif)
56 |
57 | ### Example 2 - Get imagery from 2023/04/29
58 |
59 | **Command:**
60 | ```Console
61 | GEHistoricalImagery download --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 20 --date 2023/04/29 --target-sr https://epsg.io/103248.wkt --output "./Cherry Creek 2.tif"
62 | ```
63 | Notice that the left ~30% of the image is from a different date than the rest of the image. This matches the availability shown in [Availability Map 2](availability.md#availability-map-2---imagery-from-20230429).
64 |
65 | **Output:**
66 | 
67 | [click here to download the original file](../../../raw/d607b9c7f8851316ff893ed02396c95bb55391ef/docs/assets/Cherry%20Creek%202.tif)
68 |
69 | ### Example 3 - Get imagery from 2021/08/17
70 |
71 | **Command:**
72 | ```Console
73 | GEHistoricalImagery download --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 20 --date 2021/08/17 --target-sr https://epsg.io/103248.wkt --output "./Cherry Creek 3.tif"
74 | ```
75 | Notice the L-shaped region of the image is from a different date than the rest of the image. This matches the availability shown in [Availability Map 3](availability.md#availability-map-3---imagery-from-20210517).
76 |
77 | **Output:**
78 | 
79 | [click here to download the original file](../../../raw/d607b9c7f8851316ff893ed02396c95bb55391ef/docs/assets/Cherry%20Creek%203.tif)
80 |
81 | ### Example 4 - Get imagery from Esri Wayback version 2023/04/15
82 |
83 | **NOTE : The date in this command is the date of the Wayback layer, _not the image capture date_.**
84 |
85 | **Command:**
86 | ```Console
87 | GEHistoricalImagery download --provider wayback --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 19 --date 2023/04/05 --target-sr https://epsg.io/103248.wkt --output "./Cherry Creek 4.tif"
88 | ```
89 |
90 | **Output:**
91 | 
92 | [click here to download the original file](../../../raw/d607b9c7f8851316ff893ed02396c95bb55391ef/docs/assets/Cherry%20Creek%204.tif)
93 |
94 |
95 | ************************
96 |
Updated 2025/05/15
97 |
--------------------------------------------------------------------------------
/docs/dump.md:
--------------------------------------------------------------------------------
1 | # Dump
2 | _Dump historical image tiles into a folder._
3 |
4 | This command will download historical imagery from within a region on a specified date and save all 256x256 pixel image tiles to a folder.
5 | If imagery is not available for the specified date, the downloader will use the image from the next nearest date.
6 |
7 | ## Usage
8 | ```Console
9 | GEHistoricalImagery dump --lower-left [LAT,LONG] --upper-right [LAT,LONG] -z [N] -d [yyyy/mm/dd] -o [Directory] [--format [FORMAT_STRING]] [-p [N]] [--provider [P]] [--no-cache]
10 |
11 | --lower-left=LAT,LONG Required. Geographic coordinate of the lower-left (southwest) corner
12 | of the rectangular area of interest.
13 |
14 | --upper-right=LAT,LONG Required. Geographic coordinate of the upper-right (northeast)
15 | corner of the rectangular area of interest.
16 |
17 | -z N, --zoom=N Required. Zoom level [1-24]
18 |
19 | -d yyyy/MM/dd, --date=yyyy/MM/dd Required. Imagery Date
20 |
21 | -o [Directory], --output=[Directory] Required. Output image tile save directory
22 |
23 | -f [FilenameFormat], --format=[FilenameFormat] (Default: z={Z}-Col={c}-Row={r}.jpg)
24 | Filename formatter:
25 | "{Z}" = tile's zoom level
26 | "{C}" = tile's global column number
27 | "{R}" = tile's global row number
28 | "{c}" = tile's column number within the rectangle
29 | "{r}" = tile's row number within the rectangle
30 | "{D}" = tile's image capture date
31 | "{LD}" = tile's layer date (wayback only)
32 |
33 | -p N, --parallel=N (Default: ALL_CPUS) Number of concurrent downloads
34 |
35 | --layer-date (Wayback only) The date specifies a layer instead of an image
36 | capture date
37 |
38 | --provider=TM (Default: TM) Aerial imagery provider
39 | [TM] Google Earth Time Machine
40 | [Wayback] ESRI World Imagery Wayback
41 |
42 | --no-cache (Default: false) Disable local caching
43 | ```
44 | ## Examples
45 | Download historical imagery tiles at zoom level `20` from within the region defined by the lower-left (southwest) corner `39.619819,-104.856121` and upper-right (northeast) corner `39.638393,-104.824990`.
46 |
47 | ### Example 1
48 |
49 | Save the images with filenames in the format `"Zoom={Z}, Column={c}, Row={r}.jpg"`
50 |
51 | `{Z}` will be replaced by the zoom level.
52 |
53 | `{c}` will be replaced by the column number within the rectangle, starting with column 0 along the left (west) edge of the rectangle.
54 |
55 | `{r}` will be replaced by the row number within the rectangle, starting with row 0 along the bottom (south) edge of the rectangle.
56 |
57 | **Command:**
58 | ```Console
59 | GEHistoricalImagery dump --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 20 --date 2024/06/05 -f "Zoom={Z}, Column={c}, Row={r}.jpg" -o "./Tiles"
60 | ```
61 | **Output:**
62 | ```
63 | Zoom=20, Column=00, Row=00.jpg
64 | ...
65 | Zoom=20, Column=91, Row=54.jpg
66 | ```
67 | ### Example 2
68 |
69 | Save the images with filenames in the format `"Zoom={Z}, Global Column={C}, Global Row={R}.jpg"`
70 |
71 | `{Z}` will be replaced by the zoom level.
72 |
73 | `{C}` will be replaced by the global column number.
74 |
75 | `{R}` will be replaced by the global row number.
76 |
77 | There are `2^zoom` number of global columns, beginning with column 0 at -180 degrees longitude.
78 | There are `2^zoom` number of global rows, beginning with row 0 at -180 degrees latitude. Because latitudes are constrained to \[-90,90\] degrees, only the middle half of the global rows are used.
79 |
80 | **Command:**
81 | ```Console
82 | GEHistoricalImagery dump --lower-left 39.619819,-104.856121 --upper-right 39.638393,-104.824990 --zoom 20 --date 2024/06/05 -f "Zoom={Z}, Global Column={C}, Global Row={R}.jpg" -o "./Tiles"
83 | ```
84 | **Output:**
85 | ```
86 | Zoom=20, Global Column=218872, Global Row=639689.jpg
87 | ...
88 | Zoom=20, Global Column=218963, Global Row=639743.jpg
89 | ```
90 | ## Convert Between Lat/Long and Row/Column numbers
91 |
92 | **Global** row/column numbers can be related to latitude/longitude using the following formulae:
93 | ### Google Earth Tiles
94 | $$G=\frac{360}{2^{Z}}N-180$$ or $$N=\left\lfloor \frac{G+180}{360}2^{Z} \right\rfloor$$
95 |
96 | Where:
97 |
98 | $G$ is the geographic latitude/longitude
99 | $N$ is the row/column
100 | $Z$ is the zoom level.
101 | ### Esri Tiles
102 |
103 | $$Longitude = 360\frac{Column}{2^{Z}}-180$$
104 |
105 | $$Latitude = \arctan(\sinh(\pi (1-2\frac{Row}{2^{Z}}))) \frac{180}{\pi}$$
106 | or
107 | $$Column = 2^{Z}\frac{Longitude + 180}{360}$$
108 |
109 | $$Row = \frac{2^{Z}}{2}(1 - \frac{1}{\pi}\ln(\tan(\frac{\pi\cdot Latitude}{180}) + \sec(\frac{\pi\cdot Latitude}{180})) $$
110 |
111 | Where:
112 |
113 | $Z$ is the zoom level.
114 |
115 | ************************
116 |
Updated 2025/05/16
117 |
--------------------------------------------------------------------------------
/gehinix.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | dotnet_channel="9.0"
4 | dotnet=~/.dotnet/dotnet
5 |
6 | install_dotnet() {
7 | echo "Downloading and installing the .net $dotnet_channel SDK."
8 | wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
9 | chmod +x ./dotnet-install.sh
10 | ./dotnet-install.sh --channel $dotnet_channel
11 | }
12 |
13 | if [ ! -f $dotnet ]; then
14 | install_dotnet
15 | fi
16 |
17 | dotnet_versions=$($dotnet --list-sdks)
18 | regex="9\.0\.[0-9]{3}"
19 |
20 | if [[ ! $dotnet_versions =~ $regex ]]; then
21 | install_dotnet
22 | fi
23 |
24 | projectDir="./GEHistoricalImagery-master/src/GEHistoricalImagery"
25 | csproj="$projectDir/GEHistoricalImagery.csproj"
26 | buildDir="$projectDir/bin/Release"
27 |
28 | if [ ! -f $csproj ]; then
29 | echo "Cloning the GEHistoricalImagery master repo"
30 | wget https://github.com/Mbucari/GEHistoricalImagery/archive/master.tar.gz -O GEHistoricalImagery.tar.gz
31 | tar -xf GEHistoricalImagery.tar.gz -C ./
32 | fi
33 |
34 | if [ ! -f "$buildDir/GEHistoricalImagery.dll" ]; then
35 | echo "Building GEHistoricalImagery"
36 | $dotnet build $csproj -c Release /p:DefineConstants=LINUX
37 | fi
38 |
39 | cd $buildDir
40 | $dotnet "GEHistoricalImagery.dll" "$@"
41 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/Cli/AoiVerb.cs:
--------------------------------------------------------------------------------
1 | using CommandLine;
2 | using Google.Protobuf.WellKnownTypes;
3 | using LibMapCommon;
4 | using LibMapCommon.Geometry;
5 | using System.ComponentModel;
6 |
7 | namespace GEHistoricalImagery.Cli;
8 |
9 | internal abstract class AoiVerb : OptionsBase
10 | {
11 | [Option("lower-left", SetName = "Rectangle-Corners", HelpText = "Geographic coordinate of the lower-left (southwest) corner of the rectangular area of interest.", MetaValue = "LAT,LONG")]
12 | public Wgs1984? LowerLeft { get; set; }
13 |
14 | [Option("upper-right", SetName = "Rectangle-Corners", HelpText = "Geographic coordinate of the upper-right (northeast) corner of the rectangular area of interest.", MetaValue = "LAT,LONG")]
15 | public Wgs1984? UpperRight { get; set; }
16 |
17 | [Option("region", SetName = "Region", Separator = '+', HelpText = "Geographic coordinate of the upper-right (northeast) corner of the rectangular area of interest.", MetaValue = "Lat0,Long0+Lat1,Long1+Lat2,Long2")]
18 | public IList? RegionCoordinates { get; set; }
19 |
20 | [Option('z', "zoom", HelpText = "Zoom level [1-23]", MetaValue = "N", Required = true)]
21 | public int ZoomLevel { get; set; }
22 |
23 | protected Wgs1984Poly Region { get; set; } = null!;
24 |
25 | protected IEnumerable GetAoiErrors()
26 | {
27 | if (ZoomLevel > 23)
28 | yield return $"Zoom level: {ZoomLevel} is too large. Max zoom is 23";
29 | else if (ZoomLevel < 1)
30 | yield return $"Zoom level: {ZoomLevel} is too small. Min zoom is 1";
31 |
32 | if (RegionCoordinates?.Count > 0)
33 | {
34 | var converter = TypeDescriptor.GetConverter(typeof(Wgs1984));
35 | var coords = new Wgs1984[RegionCoordinates.Count];
36 | for (int i = 0; i < RegionCoordinates.Count; i++)
37 | {
38 | if (converter.ConvertFrom(RegionCoordinates[i]) is not Wgs1984 coord)
39 | {
40 | yield return $"Invalid coordinate '{RegionCoordinates[i]}'";
41 | yield break;
42 | }
43 | coords[i] = coord;
44 | }
45 |
46 | Region = new Wgs1984Poly(coords);
47 | }
48 | else if (LowerLeft is null && UpperRight is null)
49 | yield return "An area of interest must be specified either with the 'region' option or the 'lower-left' and 'upper-right' options";
50 | else if (LowerLeft is null)
51 | yield return "Invalid lower-left coordinate.\r\n Location must be in decimal Lat,Long. e.g. 37.58289,-106.52305";
52 | else if (UpperRight is null)
53 | yield return "Invalid upper-right coordinate.\r\n Location must be in decimal Lat,Long. e.g. 37.58289,-106.52305";
54 | else
55 | {
56 | string? errorMessage = null;
57 | try
58 | {
59 | var aoi = new Rectangle(LowerLeft.Value, UpperRight.Value);
60 | Region = new Wgs1984Poly(aoi.LowerLeft, aoi.GetUpperLeft(), aoi.UpperRight, aoi.GetLowerRight());
61 | }
62 | catch (Exception e)
63 | {
64 | errorMessage = $"Invalid rectangle.\r\n {e.Message}";
65 | }
66 | if (errorMessage != null)
67 | yield return errorMessage;
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/Cli/Availability.cs:
--------------------------------------------------------------------------------
1 | using CommandLine;
2 | using Google.Protobuf.WellKnownTypes;
3 | using LibEsri;
4 | using LibGoogleEarth;
5 | using LibMapCommon.Geometry;
6 | using System.Text;
7 |
8 | namespace GEHistoricalImagery.Cli;
9 |
10 | [Verb("availability", HelpText = "Get imagery date availability in a specified region")]
11 | internal class Availability : AoiVerb
12 | {
13 | [Option('p', "parallel", HelpText = "Number of concurrent downloads", MetaValue = "N", Default = 20)]
14 | public int ConcurrentDownload { get; set; }
15 |
16 | public override async Task RunAsync()
17 | {
18 | bool hasError = false;
19 |
20 | foreach (var errorMessage in GetAoiErrors())
21 | {
22 | Console.Error.WriteLine(errorMessage);
23 | hasError = true;
24 | }
25 |
26 | if (hasError) return;
27 | Console.OutputEncoding = Encoding.Unicode;
28 |
29 | await (Provider is Provider.Wayback ? Run_Esri() : Run_Keyhole());
30 | }
31 |
32 | #region Esri
33 | private async Task Run_Esri()
34 | {
35 | if (ConcurrentDownload > 10)
36 | {
37 | ConcurrentDownload = 10;
38 | Console.Error.WriteLine($"Limiting to {ConcurrentDownload} concurrent scrapes of Esri metadata.");
39 | }
40 |
41 | var wayBack = await WayBack.CreateAsync(CacheDir);
42 |
43 | Console.Write("Loading World Atlas WayBack Layer Info: ");
44 |
45 | var all = await GetAllEsriRegions(wayBack, Region, ZoomLevel);
46 | ReplaceProgress("Done!\r\n");
47 |
48 | if (all.Sum(r => r.Availabilities.Length) == 0)
49 | {
50 | Console.Error.WriteLine($"No imagery available at zoom level {ZoomLevel}");
51 | return;
52 | }
53 |
54 | new OptionChooser().WaitForOptions(all);
55 | }
56 |
57 | private async Task GetAllEsriRegions(WayBack wayBack, Wgs1984Poly aoi, int zoomLevel)
58 | {
59 | int count = 0;
60 | int numTiles = wayBack.Layers.Count;
61 | ReportProgress(0);
62 |
63 | var mercAoi = aoi.ToWebMercator();
64 | var rect = aoi.GetBoundingRectangle();
65 | var ll = rect.LowerLeft.GetTile(ZoomLevel);
66 | var ur = rect.UpperRight.GetTile(ZoomLevel);
67 | rect.GetNumRowsAndColumns(ZoomLevel, out int nRows, out int nColumns);
68 |
69 | ParallelProcessor processor = new(ConcurrentDownload);
70 | List allLayers = new();
71 |
72 | await foreach (var region in processor.EnumerateResults(wayBack.Layers.Select(getLayerDates)))
73 | {
74 | allLayers.Add(region);
75 | ReportProgress(++count / (double)numTiles);
76 | }
77 |
78 | //De-duplicate list
79 | allLayers.Sort((a, b) => a.Layer.Date.CompareTo(b.Layer.Date));
80 |
81 | for (int i = 1; i < allLayers.Count; i++)
82 | {
83 | for (int k = i - 1; k >= 0; k--)
84 | {
85 | if (allLayers[i].Availabilities.SequenceEqual(allLayers[k].Availabilities))
86 | {
87 | allLayers.RemoveAt(i--);
88 | break;
89 | }
90 | }
91 | }
92 |
93 | return allLayers.OrderByDescending(l => l.Date).ToArray();
94 |
95 | async Task getLayerDates(Layer layer)
96 | {
97 | var regions = await wayBack.GetDateRegionsAsync(layer, mercAoi, ZoomLevel);
98 |
99 | List displays = new(regions.Length);
100 |
101 | for (int i = 0; i < regions.Length; i++)
102 | {
103 | var availability = new RegionAvailability(regions[i].Date, nRows, nColumns);
104 |
105 | foreach (var tile in Region.GetTiles(ZoomLevel))
106 | {
107 | var cIndex = tile.Column - ll.Column;
108 | var rIndex = tile.Row - ur.Row;
109 | availability[rIndex, cIndex] = regions[i].ContainsTile(tile);
110 | }
111 |
112 | if (availability.HasAnyTiles())
113 | displays.Add(availability);
114 | }
115 |
116 | return new EsriRegion(layer, displays.OrderByDescending(d => d.Date).ToArray());
117 | }
118 | }
119 |
120 | private class EsriRegion(Layer layer, RegionAvailability[] regions) : IDatedOption
121 | {
122 | public Layer Layer { get; } = layer;
123 | public RegionAvailability[] Availabilities { get; } = regions;
124 |
125 | public DateOnly Date => Layer.Date;
126 |
127 | public void DrawOption()
128 | {
129 | if (Availabilities.Length == 1)
130 | {
131 | var availabilityStr = $"Tile availability on {DateString(Layer.Date)} (captured on {DateString(Availabilities[0].Date)})";
132 | Console.WriteLine("\r\n" + availabilityStr);
133 | Console.WriteLine(new string('=', availabilityStr.Length) + "\r\n");
134 |
135 | Availabilities[0].DrawMap();
136 | }
137 | else if (Availabilities.Length > 1)
138 | {
139 | var availabilityStr = $"Layer {Layer.Title} has imagery from {Availabilities.Length} different dates";
140 | Console.WriteLine("\r\n" + availabilityStr);
141 | Console.WriteLine(new string('=', availabilityStr.Length) + "\r\n");
142 |
143 | new OptionChooser().WaitForOptions(Availabilities);
144 | }
145 | }
146 | }
147 |
148 | #endregion
149 |
150 | #region Keyhole
151 | private async Task Run_Keyhole()
152 | {
153 | var root = await DbRoot.CreateAsync(Database.TimeMachine, CacheDir);
154 | Console.Write("Loading Quad Tree Packets: ");
155 |
156 | var all = await GetAllDatesAsync(root, Region, ZoomLevel);
157 | ReplaceProgress("Done!\r\n");
158 |
159 | if (all.Length == 0)
160 | {
161 | Console.Error.WriteLine($"No dated imagery available at zoom level {ZoomLevel}");
162 | return;
163 | }
164 |
165 | new OptionChooser().WaitForOptions(all);
166 | }
167 |
168 | private async Task GetAllDatesAsync(DbRoot root, Wgs1984Poly reg, int zoomLevel)
169 | {
170 | int count = 0;
171 | int numTiles = reg.GetTileCount(zoomLevel);
172 | ReportProgress(0);
173 |
174 | ParallelProcessor> processor = new(ConcurrentDownload);
175 |
176 | var aoi = reg.GetBoundingRectangle();
177 |
178 | aoi.GetNumRowsAndColumns(zoomLevel, out int nRows, out int nColumns);
179 | var ll = aoi.LowerLeft.GetTile(ZoomLevel);
180 | var ur = aoi.UpperRight.GetTile(ZoomLevel);
181 |
182 | Dictionary uniqueDates = new();
183 | HashSet> uniquePoints = new();
184 |
185 | await foreach (var dSet in processor.EnumerateResults(reg.GetTiles(zoomLevel).Select(getDatedTiles)))
186 | {
187 | foreach (var d in dSet)
188 | {
189 | if (!uniqueDates.ContainsKey(d.Date))
190 | {
191 | uniqueDates.Add(d.Date, new RegionAvailability(d.Date, nRows, nColumns));
192 | }
193 |
194 | var region = uniqueDates[d.Date];
195 |
196 | var cIndex = d.Tile.Column - ll.Column;
197 | var rIndex = ur.Row - d.Tile.Row;
198 |
199 | uniquePoints.Add(new Tuple(rIndex, cIndex));
200 | region[rIndex, cIndex] = await root.GetNodeAsync(d.Tile) is TileNode;
201 | }
202 |
203 | ReportProgress(++count / (double)numTiles);
204 | }
205 |
206 | //Go back and mark unavailable tiles within the region of interest
207 | foreach (var a in uniqueDates.Values)
208 | {
209 | for (int r = 0; r < a.Height; r++)
210 | {
211 | for (int c = 0; c < a.Width; c++)
212 | {
213 | if (uniquePoints.Contains(new Tuple(r, c)) && a[r, c] is null)
214 | a[r, c] = false;
215 | }
216 | }
217 | }
218 |
219 | return uniqueDates.Values.OrderByDescending(r => r.Date).ToArray();
220 |
221 | async Task> getDatedTiles(KeyholeTile tile)
222 | {
223 | List dates = new();
224 |
225 | if (await root.GetNodeAsync(tile) is not TileNode node)
226 | return dates;
227 |
228 | foreach (var datedTile in node.GetAllDatedTiles())
229 | {
230 | if (datedTile.Date.Year == 1) continue;
231 |
232 | if (!dates.Any(d => d.Date == datedTile.Date))
233 | dates.Add(datedTile);
234 | }
235 | return dates;
236 | }
237 | }
238 |
239 | #endregion
240 |
241 | #region Common
242 |
243 | private class RegionAvailability : IEquatable, IDatedOption
244 | {
245 | public DateOnly Date { get; }
246 | private bool?[,] Availability { get; }
247 |
248 | public int Height => Availability.GetLength(0);
249 | public int Width => Availability.GetLength(1);
250 | public bool? this[int rIndex, int cIndex]
251 | {
252 | get => Availability[rIndex, cIndex];
253 | set => Availability[rIndex, cIndex] = value;
254 | }
255 |
256 | public RegionAvailability(DateOnly date, int height, int width)
257 | {
258 | Date = date;
259 | Availability = new bool?[height, width];
260 | }
261 |
262 | public bool HasAnyTiles() => Availability.OfType().Any(b => b);
263 | public bool Equals(RegionAvailability? other)
264 | {
265 | if (other == null || other.Date != Date || other.Height != Height || other.Width != Width)
266 | return false;
267 |
268 | for (int i = 0; i < Height; i++)
269 | {
270 | for (int j = 0; j < Width; j++)
271 | {
272 | if (other.Availability[i, j] != Availability[i, j])
273 | return false;
274 | }
275 | }
276 | return true;
277 | }
278 |
279 | public void DrawOption()
280 | {
281 | var availabilityStr = $"Tile availability on {DateString(Date)}";
282 | Console.WriteLine("\r\n" + availabilityStr);
283 | Console.WriteLine(new string('=', availabilityStr.Length) + "\r\n");
284 | DrawMap();
285 | }
286 |
287 | public void DrawMap()
288 | {
289 | /*
290 | _________________________
291 | | Top | TTTFFFNNN |
292 | ------------|------------
293 | | Bottom | TFNTFNTFN |
294 | ------------|------------
295 | | Character | █▀▀▄:˙▄. |
296 | -------------------------
297 | */
298 |
299 | for (int y = 0; y < Height; y += 2)
300 | {
301 | var has2Rows = y + 1 < Height;
302 | char[] row = new char[Width];
303 | for (int x = 0; x < Width; x++)
304 | {
305 | var top = Availability[y, x];
306 | if (has2Rows)
307 | {
308 | var bottom = Availability[y + 1, x];
309 | row[x] = top is true & bottom is true ? '█' :
310 | top is true ? '▀' :
311 | bottom is true ? '▄' :
312 | top is false & bottom is false ? ':' :
313 | top is false ? '˙' :
314 | bottom is false ? '.' : ' ';
315 | }
316 | else
317 | {
318 | row[x] = top is true ? '▀' :
319 | top is false ? '˙' : ' ';
320 | }
321 | }
322 |
323 | Console.WriteLine(new string(row));
324 | }
325 | }
326 | }
327 | #endregion
328 | }
329 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/Cli/Info.cs:
--------------------------------------------------------------------------------
1 | using CommandLine;
2 | using LibEsri;
3 | using LibGoogleEarth;
4 | using LibMapCommon;
5 |
6 | namespace GEHistoricalImagery.Cli;
7 |
8 | [Verb("info", HelpText = "Get imagery info at a specified location")]
9 | internal class Info : OptionsBase
10 | {
11 | [Option('l', "location", Required = true, HelpText = "Geographic location", MetaValue = "LAT,LONG")]
12 | public Wgs1984? Coordinate { get; set; }
13 |
14 | [Option('z', "zoom", Default = null, HelpText = "Zoom level (Optional, [0-23])", MetaValue = "N", Required = false)]
15 | public int? ZoomLevel { get; set; }
16 |
17 | public override async Task RunAsync()
18 | {
19 | if (Coordinate is null)
20 | {
21 | Console.Error.WriteLine("Invalid location coordinate.\r\n Location must be in decimal Lat,Long. e.g. 37.58289,-106.52305");
22 | return;
23 | }
24 | if (ZoomLevel < 1 || ZoomLevel > 23)
25 | {
26 | Console.Error.WriteLine("Invalid zoom level");
27 | return;
28 | }
29 |
30 | Console.WriteLine($"Dated Imagery at {Coordinate}");
31 |
32 | int startLevel = ZoomLevel ?? 1;
33 | int endLevel = ZoomLevel ?? 23;
34 |
35 | var task = Provider is Provider.Wayback ? Run_Esri(Coordinate.Value, startLevel, endLevel)
36 | : Run_Keyhole(Coordinate.Value, startLevel, endLevel);
37 |
38 | await task;
39 | }
40 |
41 | private async Task Run_Esri(Wgs1984 coordinate, int startLevel, int endLevel)
42 | {
43 | var wayBack = await WayBack.CreateAsync(CacheDir);
44 |
45 | for (int i = startLevel; i <= endLevel; i++)
46 | {
47 | var tile = coordinate.GetTile(i);
48 |
49 | Console.WriteLine($" Level = {i}");
50 | int count = 0;
51 | await foreach (var dated in wayBack.GetDatesAsync(tile))
52 | {
53 | Console.WriteLine($" layer_date = {DateString(dated.LayerDate)}, captured = {DateString(dated.CaptureDate)}");
54 | count++;
55 | }
56 |
57 | if (count == 0)
58 | {
59 | Console.Error.WriteLine($" NO AVAILABLE IMAGERY");
60 | break;
61 | }
62 | }
63 | }
64 |
65 | private async Task Run_Keyhole(Wgs1984 coordinate, int startLevel, int endLevel)
66 | {
67 | var root = await DbRoot.CreateAsync(Database.TimeMachine, CacheDir);
68 |
69 | for (int i = startLevel; i <= endLevel; i++)
70 | {
71 | var tile = coordinate.GetTile(i);
72 | var node = await root.GetNodeAsync(tile);
73 |
74 | Console.WriteLine($" Level = {i}, Path = {tile.Path}");
75 | if (node == null)
76 | {
77 | Console.Error.WriteLine($" NO AVAILABLE IMAGERY");
78 | break;
79 | }
80 | else
81 | {
82 | foreach (var dated in node.GetAllDatedTiles())
83 | {
84 | if (dated.Date.Year == 1)
85 | continue;
86 | Console.WriteLine($" date = {DateString(dated.Date)}, version = {dated.Epoch}");
87 | }
88 | }
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/Cli/OptionChooser.cs:
--------------------------------------------------------------------------------
1 | namespace GEHistoricalImagery.Cli
2 | {
3 | public interface IDatedOption
4 | {
5 | public DateOnly Date { get; }
6 | public void DrawOption();
7 | }
8 |
9 | internal class OptionChooser where T : IDatedOption
10 | {
11 | private static readonly string INDICES = "0123456789abcdefghijklmnopqrstuvwxyz";
12 | public OptionChooser() { }
13 |
14 | protected static string DateString(DateOnly date) => date.ToString("yyyy/MM/dd");
15 |
16 | public void WaitForOptions(T[] options)
17 | {
18 | if (options.Length <= INDICES.Length)
19 | WaitForSingleCharSelection(options);
20 | else
21 | WaitForMultiCharSelection(options);
22 | }
23 |
24 | private void WaitForSingleCharSelection(T[] options)
25 | {
26 | const string finalOption = "[Esc] Exit";
27 | var dateDict = options.Select((d, i) => new KeyValuePair(INDICES[i], d)).ToDictionary();
28 |
29 | WriteDateOptions(dateDict, finalOption);
30 |
31 | while (Console.ReadKey(true) is ConsoleKeyInfo key && key.Key != ConsoleKey.Escape)
32 | {
33 | if (dateDict.TryGetValue(key.KeyChar, out var option))
34 | {
35 | option.DrawOption();
36 | Console.WriteLine();
37 | WriteDateOptions(dateDict, finalOption);
38 | }
39 | }
40 | }
41 |
42 | private void WaitForMultiCharSelection(T[] options)
43 | {
44 | const string finalOption = "[E] Exit";
45 |
46 | int numPlaces = (int)Math.Ceiling(Math.Log10(options.Length));
47 | var decFormat = "D" + numPlaces;
48 | var dateDict = options.Select((d, i) => new KeyValuePair(i.ToString(decFormat), d)).ToDictionary();
49 |
50 | WriteDateOptions(dateDict, finalOption);
51 | while (Console.ReadLine() is string key && !string.Equals(key, "E", StringComparison.OrdinalIgnoreCase))
52 | {
53 | if (dateDict.TryGetValue(key, out var option))
54 | {
55 | option.DrawOption();
56 | Console.WriteLine();
57 | WriteDateOptions(dateDict, finalOption);
58 | }
59 | }
60 | }
61 |
62 | private static void WriteDateOptions(IEnumerable> dateDict, string finalOption) where S : notnull
63 | {
64 | const string spacer = " ";
65 |
66 | foreach (var entry in dateDict.Select((kvp, i) => $"[{kvp.Key}] {DateString(kvp.Value.Date)}").Append(finalOption))
67 | {
68 | Console.Write(entry);
69 |
70 | var remainingSpace = Console.WindowWidth - Console.CursorLeft;
71 |
72 | if (remainingSpace < entry.Length + spacer.Length)
73 | Console.WriteLine();
74 | else
75 | Console.Write(spacer);
76 | }
77 | if (Console.CursorLeft > 0)
78 | Console.WriteLine();
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/Cli/OptionsBase.cs:
--------------------------------------------------------------------------------
1 | using CommandLine;
2 |
3 | namespace GEHistoricalImagery.Cli;
4 |
5 | public enum Provider
6 | {
7 | TM,
8 | Wayback
9 | }
10 |
11 | internal abstract class OptionsBase
12 | {
13 | [Option("provider", MetaValue = "TM", Default = Provider.TM, HelpText = "Aerial imagery provider\r\n [TM] Google Earth Time Machine\r\n [Wayback] ESRI World Imagery Wayback")]
14 | public Provider Provider { get; set; }
15 |
16 | [Option("no-cache", HelpText = "Disable local caching", Default = false)]
17 | public bool DisableCache { get; set; }
18 | public abstract Task RunAsync();
19 |
20 | protected string? CacheDir
21 | => DisableCache ? null
22 | : Environment.GetEnvironmentVariable("GEHistoricalImagery_Cache") is string cacheDir ? cacheDir
23 | : "./cache";
24 |
25 | public double Progress { get; set; }
26 |
27 | private int lastProgLen;
28 | protected void ReportProgress(double progress)
29 | {
30 | lock (this)
31 | {
32 | if (progress >= Progress)
33 | {
34 | var p = progress.ToString("P");
35 | Console.Write(new string('\b', lastProgLen) + p);
36 | lastProgLen = p.Length;
37 | Progress = progress;
38 | }
39 | }
40 | }
41 |
42 | protected void ReplaceProgress(string text)
43 | {
44 | var newText = new string('\b', lastProgLen);
45 |
46 | newText = newText + new string(' ', lastProgLen) + newText + text;
47 |
48 | Console.Write(newText);
49 | Progress = 0;
50 | lastProgLen = 0;
51 | }
52 |
53 | protected static string DateString(DateOnly date) => date.ToString("yyyy/MM/dd");
54 | }
55 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/CoordinateSystem.cs:
--------------------------------------------------------------------------------
1 | using OSGeo.OSR;
2 | using System.Diagnostics.CodeAnalysis;
3 |
4 | namespace GEHistoricalImagery;
5 |
6 | public class CoordinateSystem : IDisposable
7 | {
8 | public SpatialReference SpatialReference { get; }
9 | public string Name { get; }
10 | public bool IsGeographic { get; }
11 | public int XAxis { get; } = -1;
12 | public int YAxis { get; } = -1;
13 | public int ZAxis { get; } = -1;
14 | public double LinearUnits { get; }
15 |
16 | private CoordinateSystem(SpatialReference sr)
17 | {
18 | SpatialReference = sr;
19 | Name = SpatialReference.GetName();
20 | LinearUnits = sr.GetLinearUnits();
21 | IsGeographic = SpatialReference.IsGeographic() != 0;
22 |
23 | var axisCount = sr.GetAxesCount();
24 | for (int a = 0; a < axisCount; a++)
25 | {
26 | switch (sr.GetAxisOrientation(null, a))
27 | {
28 | case AxisOrientation.OAO_East:
29 | case AxisOrientation.OAO_West:
30 | XAxis = a;
31 | break;
32 | case AxisOrientation.OAO_North:
33 | case AxisOrientation.OAO_South:
34 | YAxis = a;
35 | break;
36 | case AxisOrientation.OAO_Up:
37 | case AxisOrientation.OAO_Down:
38 | ZAxis = a;
39 | break;
40 | }
41 | }
42 |
43 | //Most coordinate systems don't have a 3rd axis, but just in case
44 | if (axisCount < 3)
45 | ZAxis = 2;
46 | }
47 |
48 | public static bool TryParse(string csText, [NotNullWhen(true)] out CoordinateSystem? cs)
49 | {
50 | var sr = new SpatialReference("");
51 |
52 | try
53 | {
54 | if (sr.SetFromUserInput(csText) == 0)
55 | {
56 | cs = new(sr);
57 | return cs.XAxis != -1 && cs.YAxis != -1 && cs.ZAxis != -1;
58 | }
59 | }
60 | catch { sr.Dispose(); }
61 | cs = null;
62 | return false;
63 | }
64 |
65 | public void Dispose() => SpatialReference.Dispose();
66 | public override string ToString() => Name;
67 | }
68 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/EarthImage.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon;
2 | using LibMapCommon.Geometry;
3 | using OSGeo.GDAL;
4 | using OSGeo.OSR;
5 |
6 | namespace GEHistoricalImagery;
7 |
8 | internal class EarthImage : IDisposable where T : ICoordinate
9 | {
10 | public const int TILE_SZ = 256;
11 | protected int Width { get; init; }
12 | protected int Height { get; init; }
13 |
14 | protected Dataset? TempDataset { get; init; }
15 |
16 | /// The x-coordinate of the output dataset's top-left corner relative to global pixel space
17 | protected int RasterX { get; init; }
18 |
19 | /// The y-coordinate of the output dataset's top-left corner relative to global pixel space
20 | protected int RasterY { get; init; }
21 |
22 | static EarthImage()
23 | {
24 | #if LINUX
25 | Gdal.AllRegister();
26 | #else
27 | GdalConfiguration.ConfigureGdal();
28 | #endif
29 | Gdal.SetCacheMax(1024 * 1024 * 300);
30 | }
31 |
32 | public EarthImage(Wgs1984Poly region, int level, string? cacheFile = null)
33 | {
34 | var rectangle = region.GetBoundingRectangle();
35 | long globalPixels = TILE_SZ * (1L << level);
36 |
37 | var upperLeft = rectangle.GetUpperLeft();
38 |
39 | var pxUl = upperLeft.GetGlobalPixelCoordinate(level);
40 | var pxLr = rectangle.GetLowerRight().GetGlobalPixelCoordinate(level);
41 |
42 | RasterX = pxUl.X.ToRoundedInt();
43 | RasterY = pxUl.Y.ToRoundedInt();
44 |
45 | Width = (pxLr.X - pxUl.X).ToRoundedInt();
46 | Height = (pxLr.Y - pxUl.Y).ToRoundedInt();
47 | //Allow wrapping around 180/-180
48 | if (Width < 0)
49 | Width = (int)(Width + globalPixels);
50 |
51 | using var sourceSr = new SpatialReference("");
52 | sourceSr.ImportFromEPSG(T.EpsgNumber);
53 |
54 | var geoTransform = new GeoTransform
55 | {
56 | UpperLeft_X = upperLeft.X,
57 | UpperLeft_Y = upperLeft.Y,
58 | PixelWidth = T.Equator / globalPixels,
59 | PixelHeight = T.Equator / globalPixels
60 | };
61 |
62 | TempDataset = CreateEmptyDataset(cacheFile);
63 | TempDataset.SetSpatialRef(sourceSr);
64 | TempDataset.SetGeoTransform(geoTransform);
65 | }
66 |
67 | protected Dataset CreateEmptyDataset(string? fileName)
68 | {
69 | if (string.IsNullOrWhiteSpace(fileName))
70 | {
71 | using var tifDriver = Gdal.GetDriverByName("MEM");
72 | return tifDriver.Create("", Width, Height, 3, DataType.GDT_Byte, null);
73 | }
74 | else
75 | {
76 | using var tifDriver = Gdal.GetDriverByName("GTiff");
77 | return tifDriver.Create(fileName, Width, Height, 3, DataType.GDT_Byte, null);
78 | }
79 | }
80 |
81 | public void AddTile(ITile tile, Dataset image)
82 | {
83 | //Tile's global pixel coordinates of the tile's top-left corner.
84 | var gpx = tile.GetTopLeftPixel();
85 |
86 | int gpx_x = (int)gpx.X;
87 | int gpx_y = (int)gpx.Y;
88 |
89 | //The tile is entirely to the left of the region, so wrap around the globe.
90 | if (gpx_x + TILE_SZ < RasterX)
91 | gpx_x += (1 << tile.Level) * TILE_SZ;
92 |
93 | //Pixel coordinate to read the tile's data, relative to the tile's top-left corner.
94 | int read_x = int.Max(0, RasterX - gpx_x);
95 | int read_y = int.Max(0, RasterY - gpx_y);
96 |
97 | //Pixel coordinate to write the data, relative to output dataset's top-left corner.
98 | int write_x = gpx_x + read_x - RasterX;
99 | int write_y = gpx_y + read_y - RasterY;
100 |
101 | //Raster dimensions to read/write
102 | int size_x = int.Min(TILE_SZ - read_x, Width - write_x);
103 | int size_y = int.Min(TILE_SZ - read_y, Height - write_y);
104 |
105 | if (size_x <= 0 || size_y <= 0)
106 | return;
107 |
108 | int bandCount = image.RasterCount;
109 | var bandMap = Enumerable.Range(1, bandCount).ToArray();
110 | var rasterBuff = GC.AllocateUninitializedArray(size_x * size_y * bandCount);
111 | image.ReadRaster(read_x, read_y, size_x, size_y, rasterBuff, size_x, size_y, bandCount, bandMap, bandCount, size_x * bandCount, 1);
112 | TempDataset?.WriteRaster(write_x, write_y, size_x, size_y, rasterBuff, size_x, size_y, bandCount, bandMap, bandCount, size_x * bandCount, 1);
113 | }
114 |
115 | public void Save(string path, string? outSR, int cpuCount, double scale, double offsetX, double offsetY, bool scaleFirst)
116 | {
117 | if (TempDataset == null) return;
118 | TempDataset.FlushCache();
119 |
120 | Dataset saved;
121 |
122 | if (outSR != null)
123 | {
124 | string[] parameters =
125 | [
126 | "-multi",
127 | "-wo", $"NUM_THREADS={cpuCount}",
128 | "-of", "GTiff",
129 | "-ot", "Byte",
130 | "-wo", "OPTIMIZE_SIZE=TRUE",
131 | "-co", "COMPRESS=JPEG",
132 | "-co", "PHOTOMETRIC=YCBCR",
133 | "-co", "TILED=TRUE",
134 | "-r", "bilinear",
135 | "-s_srs", $"EPSG:{T.EpsgNumber}",
136 | "-t_srs", outSR
137 | ];
138 | using var options = new GDALWarpAppOptions(parameters);
139 | saved = Gdal.Warp(path, [TempDataset], options, reportProgress, null);
140 | }
141 | else
142 | {
143 | string[] parameters =
144 | [
145 | "COMPRESS=JPEG",
146 | "PHOTOMETRIC=YCBCR",
147 | "TILED=TRUE",
148 | $"NUM_THREADS={cpuCount}"
149 | ];
150 | using var tifDriver = Gdal.GetDriverByName("GTiff");
151 | saved = tifDriver.CreateCopy(path, TempDataset, 1, parameters, reportProgress, null);
152 | }
153 |
154 | using (saved)
155 | {
156 | var geoTransform = saved.GetGeoTransform();
157 |
158 | if (scaleFirst)
159 | geoTransform.Scale(scale);
160 |
161 | geoTransform.Translate(offsetX, offsetY);
162 |
163 | if (!scaleFirst)
164 | geoTransform.Scale(scale);
165 |
166 | saved.SetGeoTransform(geoTransform);
167 | saved.FlushCache();
168 |
169 | var worldFileExtension = Path.GetExtension(path) switch
170 | {
171 | ".gif" or ".giff" => ".gfw",
172 | ".jpg" or ".jpeg" => ".jgw",
173 | ".tif" or ".tiff" => ".tfw",
174 | ".png" => ".pgw",
175 | ".jp2" => ".j2w",
176 | _ => ".worldfile"
177 | };
178 |
179 | var worldFile = Path.ChangeExtension(path, worldFileExtension);
180 | using var sw = new StreamWriter(worldFile);
181 | sw.WriteLine(geoTransform.PixelWidth);
182 | sw.WriteLine(geoTransform.ColumnRotation);
183 | sw.WriteLine(geoTransform.RowRotation);
184 | sw.WriteLine(geoTransform.PixelHeight);
185 | sw.WriteLine(geoTransform.UpperLeft_X);
186 | sw.WriteLine(geoTransform.UpperLeft_Y);
187 | }
188 |
189 | int reportProgress(double Complete, IntPtr Message, IntPtr Data)
190 | {
191 | var args = new ImageSaveEventArgs(Complete);
192 | Saving?.Invoke(this, args);
193 | return args.Continue ? 1 : 0;
194 | }
195 | }
196 |
197 | public event EventHandler? Saving;
198 |
199 | public void Dispose()
200 | {
201 | TempDataset?.FlushCache();
202 | TempDataset?.Dispose();
203 | }
204 | }
205 |
206 | public class ImageSaveEventArgs : EventArgs
207 | {
208 | public double Progress { get; }
209 | public bool Continue { get; } = true;
210 |
211 | internal ImageSaveEventArgs(double progress)
212 | {
213 | Progress = progress;
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/GEHistoricalImagery.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | Exe
4 | net9.0
5 | enable
6 | enable
7 | 0.2.2.2
8 | true
9 | Speed
10 | false
11 | false
12 | Always
13 |
14 |
15 |
16 | none
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | <_Parameter1>GEHistoricalImageryTest
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/OSGeo.GDAL/GDALExtensions.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon.Geometry;
2 | using LibMapCommon;
3 | using OSGeo.OGR;
4 | using OSGeo.OSR;
5 |
6 | namespace OSGeo.GDAL;
7 |
8 | [Flags]
9 | public enum GDAL_OF : uint
10 | {
11 | READONLY = 0,
12 | ALL = READONLY,
13 | UPDATE = 1,
14 | RASTER = 2,
15 | VECTOR = 4,
16 | GNM = 8,
17 | MULTIDIM_RASTER = 0x10,
18 | SHARED = 0x20,
19 | VERBOSE_ERROR = 0x40,
20 | INTERNAL = 0x80,
21 | ARRAY_BLOCK_ACCESS = 0x100,
22 | HASHSET_BLOCK_ACCESS = ARRAY_BLOCK_ACCESS
23 | }
24 |
25 | internal static class GDALExtensions
26 | {
27 | public static GeoTransform GetGeoTransform(this Dataset dataset)
28 | {
29 | var geoTransform = new GeoTransform();
30 | dataset.GetGeoTransform(geoTransform.Transformation);
31 | return geoTransform;
32 | }
33 |
34 | public static void SetGeoTransform(this Dataset dataset, GeoTransform transform)
35 | {
36 | dataset.SetGeoTransform(transform.Transformation);
37 | }
38 |
39 | public record ShapePolygon(Wgs1984Poly Polygon, Dictionary Features);
40 | public static IEnumerable GetPolygons(this DataSource shp)
41 | {
42 | if (shp.GetLayerCount() == 0)
43 | yield break;
44 |
45 | using var t_sr = new SpatialReference("");
46 | t_sr.ImportFromEPSG(Wgs1984.EpsgNumber);
47 |
48 | for (int i = shp.GetLayerCount() - 1; i >= 0; i--)
49 | {
50 | using var layer = shp.GetLayerByIndex(i);
51 | if (layer.GetGeomType() is not wkbGeometryType.wkbPolygon)
52 | continue;
53 |
54 | using var s_sr = layer.GetSpatialRef();
55 | using var xForm = new CoordinateTransformation(s_sr, t_sr);
56 |
57 | for (Feature? feature; (feature = layer.GetNextFeature()) is not null; feature.Dispose())
58 | {
59 | using var geometry = feature.GetGeometryRef();
60 | using var ring = geometry.GetGeometryRef(0);
61 | if (ring.GetGeometryType() is not wkbGeometryType.wkbLineString and not wkbGeometryType.wkbLinearRing)
62 | continue;
63 |
64 | var numPoints = ring.GetPointCount();
65 | if (numPoints < 3)
66 | continue;
67 |
68 | var featureCount = feature.GetFieldCount();
69 | var features = new Dictionary(featureCount);
70 | for (int f = 0; f < featureCount; f++)
71 | {
72 | using var field = feature.GetFieldDefnRef(f);
73 | features[field.GetName()] = feature.GetFieldAsString(f);
74 | }
75 |
76 | var points = new Wgs1984[numPoints];
77 | var point = new double[3];
78 | for (int j = 0; j < numPoints; j++)
79 | {
80 | ring.GetPoint(j, point);
81 | xForm.TransformPoint(point);
82 | points[j] = new Wgs1984(point[0], point[1]);
83 | }
84 |
85 | if (points[0].Equals(points[^1]))
86 | {
87 | if (points.Length < 3)
88 | continue;
89 | Array.Resize(ref points, points.Length - 1);
90 | }
91 |
92 | yield return new ShapePolygon(new Wgs1984Poly(points), features);
93 | }
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/OSGeo.GDAL/GeoTransform.cs:
--------------------------------------------------------------------------------
1 | namespace OSGeo.GDAL;
2 |
3 | public readonly record struct GeoTransform
4 | {
5 | public double UpperLeft_X { get => Transformation[0]; set => Transformation[0] = value; }
6 | public double PixelWidth { get => Transformation[1]; set => Transformation[1] = value; }
7 | public double RowRotation { get => Transformation[2]; set => Transformation[2] = value; }
8 | public double UpperLeft_Y { get => Transformation[3]; set => Transformation[3] = value; }
9 | public double ColumnRotation { get => Transformation[4]; set => Transformation[4] = value; }
10 | public double PixelHeight { get => Transformation[5]; set => Transformation[5] = value; }
11 |
12 | public readonly double[] Transformation;
13 | private const int NUM_PARAMS = 6;
14 |
15 | public GeoTransform()
16 | {
17 | Transformation = new double[NUM_PARAMS];
18 | }
19 |
20 | public void Scale(double scale)
21 | {
22 | for (int i = 0; i < Transformation.Length; i++)
23 | Transformation[i] *= scale;
24 | }
25 |
26 | public void Translate(double x, double y)
27 | {
28 | UpperLeft_X += x;
29 | UpperLeft_Y += y;
30 | }
31 | }
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/ParallelProcessor.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 |
3 | namespace GEHistoricalImagery;
4 |
5 | internal class ParallelProcessor
6 | {
7 | private int _parallelism;
8 | public int Parallelism
9 | {
10 | get => _parallelism;
11 | set
12 | {
13 | ArgumentOutOfRangeException.ThrowIfLessThan(value, 1, nameof(Parallelism));
14 | ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 100, nameof(Parallelism));
15 | _parallelism = value;
16 | }
17 | }
18 | public ParallelProcessor(int parallelism)
19 | {
20 | Parallelism = parallelism;
21 | }
22 |
23 | public IAsyncEnumerable EnumerateResults(IEnumerable> generator, CancellationToken cancellationToken = default)
24 | => EnumerateResults(generator.Select(Task.Run), cancellationToken);
25 |
26 | public IAsyncEnumerable EnumerateResults(IEnumerable?>> generator, CancellationToken cancellationToken = default)
27 | => EnumerateResults(generator.Select(Task.Run), cancellationToken);
28 |
29 | public async IAsyncEnumerable EnumerateResults(IEnumerable> generator, [EnumeratorCancellation] CancellationToken cancellationToken = default)
30 | {
31 | Task?[] tasks = new Task[Parallelism];
32 | int taskCount = 0;
33 |
34 | foreach (var t in generator)
35 | {
36 | int newParallelism;
37 |
38 | while (taskCount >= (newParallelism = Parallelism) && !cancellationToken.IsCancellationRequested)
39 | yield return await popOne();
40 |
41 | if (cancellationToken.IsCancellationRequested)
42 | yield break;
43 |
44 | if (tasks.Length != newParallelism)
45 | {
46 | var newTasks = new Task[newParallelism];
47 | Array.Copy(tasks, 0, newTasks, 0, taskCount);
48 | tasks = newTasks;
49 | }
50 |
51 | if (taskCount < tasks.Length)
52 | pushOne(t);
53 | }
54 |
55 | while (taskCount > 0 && !cancellationToken.IsCancellationRequested)
56 | yield return await popOne();
57 |
58 | void pushOne(Task task)
59 | => tasks[taskCount++] = task;
60 |
61 | async Task popOne()
62 | {
63 | var completedTask = await Task.WhenAny(tasks.OfType>());
64 | var completedIndex = Array.IndexOf(tasks, completedTask);
65 | tasks[completedIndex] = null;
66 | taskCount--;
67 | (tasks[completedIndex], tasks[taskCount]) = (tasks[taskCount], tasks[completedIndex]);
68 | return completedTask.Result;
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/GEHistoricalImagery/Program.cs:
--------------------------------------------------------------------------------
1 | using CommandLine;
2 | using GEHistoricalImagery.Cli;
3 | using System.Diagnostics.CodeAnalysis;
4 |
5 | namespace GEHistoricalImagery;
6 |
7 | public enum ExitCode
8 | {
9 | ProcessCompletedSuccessfully = 0,
10 | NonRunNonError = 1,
11 | ParseError = 2,
12 | RunTimeError = 3
13 | }
14 |
15 | internal class Program
16 | {
17 | private static void ConfigureParser(ParserSettings settings)
18 | {
19 | settings.AutoVersion = true;
20 | settings.AutoHelp = true;
21 | settings.HelpWriter = Console.Error;
22 | settings.CaseInsensitiveEnumValues = true;
23 | }
24 |
25 | [STAThread]
26 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Info))]
27 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Availability))]
28 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Download))]
29 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Dump))]
30 | private static async Task Main(string[] args)
31 | {
32 | var parser = new Parser(ConfigureParser);
33 |
34 | var result = parser.ParseArguments(args, typeof(Info), typeof(Availability), typeof(Download), typeof(Dump));
35 |
36 | try
37 | {
38 | await result.WithParsedAsync(opt => opt.RunAsync());
39 | }
40 | catch (Exception ex)
41 | {
42 | Console.Error.WriteLine("An error occurred:\r\n\r\n" + ex.ToString());
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/LibEsri/Capabilities.cs:
--------------------------------------------------------------------------------
1 | using System.Xml.Linq;
2 |
3 | namespace LibEsri;
4 |
5 | internal class Capabilities
6 | {
7 | public readonly Layer[] Layers;
8 | private Capabilities(XElement capabilities, Layer[] layers)
9 | {
10 | Layers = layers;
11 | }
12 |
13 | public static async Task LoadAsync(Stream xmlStream)
14 | {
15 | var document = await XDocument.LoadAsync(xmlStream, LoadOptions.None, default);
16 |
17 | if (document.Root is not XElement capsXml)
18 | return null;
19 |
20 | var ns = capsXml.GetDefaultNamespace();
21 |
22 | if (capsXml.Element(ns + "Contents") is not XElement contentsXml)
23 | return null;
24 |
25 | var layers = contentsXml.Elements(ns + "Layer").Select(Layer.Parse).ToArray();
26 |
27 | return new Capabilities(capsXml, layers);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/LibEsri/DatedEsriTile.cs:
--------------------------------------------------------------------------------
1 | namespace LibEsri;
2 |
3 | public class DatedEsriTile
4 | {
5 | public DateOnly LayerDate { get; }
6 | public DateOnly CaptureDate { get; }
7 | public Layer Layer { get; }
8 | public EsriTile Tile { get; }
9 |
10 | internal DatedEsriTile(DateOnly captureDate, Layer layer, EsriTile tile)
11 | {
12 | CaptureDate = captureDate;
13 | LayerDate = layer.Date;
14 | Layer = layer;
15 | Tile = tile;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/LibEsri/EsriExtensions.cs:
--------------------------------------------------------------------------------
1 | using LibEsri.Geometry;
2 | using LibMapCommon;
3 | using LibMapCommon.Geometry;
4 | using System.Text.Json.Nodes;
5 |
6 | namespace LibEsri;
7 |
8 | public static class EsriExtensions
9 | {
10 | internal static IEnumerable ToDatedRegions(this JsonArray? jsonArray, Layer layer, WebMercatorPoly region)
11 | {
12 | if (jsonArray is null || jsonArray.Count == 0)
13 | yield break;
14 |
15 | foreach (var f in jsonArray.OfType())
16 | {
17 | if (f?["attributes"]?["SRC_DATE2"]?.GetValue() is not long dateNum)
18 | continue;
19 |
20 | if (f?["geometry"]?["rings"]?.AsArray().ToRings().ToArray() is not WebMercatorPoly[] rings)
21 | continue;
22 |
23 | var dateOnly = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(dateNum).DateTime);
24 | yield return new DatedRegion(dateOnly, rings, region);
25 | }
26 | }
27 |
28 | private static IEnumerable ToRings(this JsonArray? jsonArray)
29 | {
30 | if (jsonArray is null || jsonArray.Count == 0)
31 | yield break;
32 |
33 | foreach (var r in jsonArray.OfType())
34 | {
35 | var coordinates = r.ToCoordinates();
36 |
37 | if (coordinates.Any())
38 | yield return new WebMercatorPoly(coordinates);
39 | }
40 | }
41 |
42 | private static IEnumerable ToCoordinates(this JsonArray? jsonArray)
43 | {
44 | if (jsonArray is null || jsonArray.Count == 0)
45 | yield break;
46 |
47 | foreach (var c in jsonArray.OfType())
48 | {
49 | if (c.Count == 2 &&
50 | c[0]?.GetValue() is double x &&
51 | c[1]?.GetValue() is double y)
52 | yield return new WebMercator(x, y);
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/LibEsri/EsriTile.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon;
2 |
3 | namespace LibEsri;
4 |
5 | public class EsriTile : ITile
6 | {
7 | public int Level { get; }
8 | /// The number of rows from the top-most (north-most) edge of the map.
9 | public int Row { get; }
10 | public int Column { get; }
11 |
12 | public const int MaxLevel = 23;
13 |
14 | public EsriTile(int rowIndex, int colIndex, int level)
15 | {
16 | Level = level;
17 | Row = rowIndex;
18 | Column = colIndex;
19 | }
20 |
21 | private Wgs1984 ToCoordinate(double column, double row)
22 | {
23 | var n = Math.Pow(2, Level);
24 |
25 | var lon_deg = column / n * 360d - 180d;
26 | var lat_rad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * row / n)));
27 | var lat_deg = lat_rad * 180.0 / Math.PI;
28 | return new Wgs1984(lat_deg, lon_deg);
29 | }
30 |
31 | public Wgs1984 LowerLeft => ToCoordinate(Column, Row + 1);
32 | public Wgs1984 LowerRight => ToCoordinate(Column + 1, Row + 1);
33 | public Wgs1984 UpperLeft => ToCoordinate(Column, Row);
34 | public Wgs1984 UpperRight => ToCoordinate(Column + 1, Row);
35 | public Wgs1984 Center => ToCoordinate(Column + 0.5, Row + 0.5);
36 |
37 | ///
38 | /// Gets the number of columns between teo s. May span 180/-180
39 | ///
40 | /// The left (western) of the region
41 | /// The right (eastern) of the region
42 | /// The column span
43 | /// thrown if boh s do not have the same
44 | public static int ColumnSpan(EsriTile leftTile, EsriTile rightTile)
45 | {
46 | if (leftTile.Level != rightTile.Level)
47 | throw new ArgumentException("Tile levels do not match", nameof(rightTile));
48 |
49 | return Util.Mod(rightTile.Column - leftTile.Column, 1 << rightTile.Level);
50 | }
51 |
52 | public static EsriTile GetTile(Wgs1984 coordinate, int level)
53 | {
54 | var size = Util.ValidateLevel(level, MaxLevel);
55 |
56 | var webCoord = coordinate.ToWebMercator();
57 | var column = (0.5 + webCoord.X / WebMercator.Equator) * size;
58 | var row = (0.5 - webCoord.Y / WebMercator.Equator) * size;
59 |
60 | return new EsriTile((int)row, (int)column, level);
61 | }
62 |
63 | public static EsriTile GetMinimumCorner(Wgs1984 c1, Wgs1984 c2, int level)
64 | {
65 | var topMost = Math.Max(c1.Latitude, c2.Latitude);
66 | var leftMost = Math.Min(c1.Longitude, c2.Longitude);
67 | return GetTile(new Wgs1984(topMost, leftMost), level);
68 | }
69 |
70 | public static EsriTile Create(int row, int col, int level)
71 | => new EsriTile(row, col, level);
72 | }
73 |
--------------------------------------------------------------------------------
/src/LibEsri/Geometry/DatedRegion.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon.Geometry;
2 |
3 | namespace LibEsri.Geometry;
4 |
5 | public class DatedRegion
6 | {
7 | public DateOnly Date { get; }
8 | internal WebMercatorPoly[] Rings { get; private set; }
9 |
10 | internal DatedRegion(DateOnly date, WebMercatorPoly[] rings, WebMercatorPoly? clippingRegion = null)
11 | {
12 | Date = date;
13 | Rings = clippingRegion is null ? rings : rings.SelectMany(r => r.Clip(clippingRegion)).ToArray();
14 | }
15 |
16 | public bool ContainsTile(EsriTile tile) => Rings.Any(r => r.ContainsTile(tile));
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/src/LibEsri/Layer.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon;
2 | using LibMapCommon.Geometry;
3 | using System.Diagnostics;
4 | using System.Xml.Linq;
5 |
6 | namespace LibEsri;
7 |
8 | public class Layer
9 | {
10 | private static readonly XNamespace Ows = "https://www.opengis.net/ows/1.1";
11 | public string Title { get; init; }
12 | public DateOnly Date { get; }
13 | public int ID { get; }
14 | public string Identifier { get; }
15 | public string Format { get; }
16 | public string[] TileMatrixSets { get; }
17 | public string ResourceURL { get; }
18 |
19 | private Layer(string title, string identifier, string format, string resourceUrl, string[] matrixSets)
20 | {
21 | Title = title;
22 | Date = GetLayerDate(title);
23 | Identifier = identifier;
24 | Format = format;
25 | ResourceURL = resourceUrl;
26 | TileMatrixSets = matrixSets;
27 | ID = GetId(ResourceURL);
28 | }
29 |
30 | internal static Layer Parse(XElement layer)
31 | {
32 | var ns = layer.GetDefaultNamespace();
33 | var title = GetElementByName(layer, Ows, "Title").Value;
34 | var identifier = GetElementByName(layer, Ows, "Identifier").Value;
35 | var format = GetElementByName(layer, ns, "Format").Value;
36 | var resourceUrl = GetAttributeByName(GetElementByName(layer, ns, "ResourceURL"), "template").Value;
37 | var matrixSets = layer.Elements(ns + "TileMatrixSetLink").Select(e => GetElementByName(e, ns, "TileMatrixSet")).OfType().Select(e => e.Value).ToArray();
38 |
39 | return new Layer(title, identifier, format, resourceUrl, matrixSets);
40 | }
41 |
42 | private string GetMetadataUrl(int level, bool returnGeometry, params string[] outFields)
43 | {
44 | const string KEY_TEXT = "/World_Imagery";
45 |
46 | var scale = int.Min(13, 23 - level);
47 |
48 | int start = ResourceURL.IndexOf("//") + 2;
49 | int end2 = ResourceURL.IndexOf('.', start);
50 |
51 | var newDomain = ResourceURL.Substring(0, start) + "metadata" + ResourceURL.Substring(end2);
52 |
53 | int end = newDomain.IndexOf(KEY_TEXT) + KEY_TEXT.Length;
54 |
55 | var retStr = returnGeometry ? "true" : "false";
56 | var query = string.Join(",", outFields);
57 |
58 | var url = newDomain.Substring(0, end) + "_Metadata" + Identifier.Replace("WB", "").ToLowerInvariant() +
59 | $"/MapServer/{scale}/query?f=json&where=1%3D1&outFields={query}&returnGeometry={retStr}";
60 |
61 | return url;
62 | }
63 |
64 | public string GetEnvelopeQueryUrl(WebMercatorPoly region, int level)
65 | {
66 | string[] points = new string[region.Edges.Length + 1];
67 |
68 | for (int i = 0; i < region.Edges.Length; i++)
69 | points[i] = $"%5B{region.Edges[i].Origin.X},{region.Edges[i].Origin.Y}%5D";
70 | points[^1] = points[0];
71 |
72 | var ring = $"%7B%22rings%22%3A%5B%5B{string.Join("%2C", points)}%5D%5D%2C%22spatialReference%22%3A%7B%22wkid%22%3A{WebMercator.EpsgNumber}%7D%7D";
73 |
74 | var metadataUrl
75 | = GetMetadataUrl(level, returnGeometry: true, "SRC_DATE2")
76 | + "&geometryType=esriGeometryPolygon&spatialRel=esriSpatialRelIntersects&geometry="
77 | + ring;
78 | return metadataUrl;
79 | }
80 |
81 | public string GetPointQueryUrl(EsriTile tile)
82 | {
83 | var center = tile.Center;
84 |
85 | var metadataUrl
86 | = GetMetadataUrl(tile.Level, returnGeometry: false, "SRC_DATE2")
87 | + "&geometryType=esriGeometryPoint&spatialRel=esriSpatialRelIntersects&geometry="
88 | + $"%7B%22spatialReference%22%3A%7B%22wkid%22%3A4326%7D%2C%22x%22%3A{center.Longitude}%2C%22y%22%3A{center.Latitude}%7D";
89 | return metadataUrl;
90 | }
91 |
92 | public string GetTileMapUrl(EsriTile tile)
93 | {
94 | const string KEY_TEXT = "/World_Imagery";
95 | int end = ResourceURL.IndexOf(KEY_TEXT) + KEY_TEXT.Length;
96 | var url = ResourceURL.Substring(0, end) + "/MapServer/tilemap";
97 |
98 | return $"{url}/{ID}/{tile.Level}/{tile.Row}/{tile.Column}";
99 | }
100 |
101 | public string GetAssetUrl(EsriTile tile)
102 | => ResourceURL
103 | .Replace("{TileMatrixSet}", TileMatrixSets[0])
104 | .Replace("{TileMatrix}", tile.Level.ToString())
105 | .Replace("{TileRow}", tile.Row.ToString())
106 | .Replace("{TileCol}", tile.Column.ToString());
107 |
108 | public override string ToString() => Title;
109 |
110 | private static int GetId(string resourceURL)
111 | {
112 | const string KEY_TEXT = "/MapServer/tile/";
113 |
114 | int start = resourceURL.IndexOf(KEY_TEXT) + KEY_TEXT.Length;
115 | int end = resourceURL.IndexOf('/', start);
116 | var idString = resourceURL.Substring(start, end - start);
117 | return int.Parse(idString);
118 | }
119 |
120 | private static DateOnly GetLayerDate(string title)
121 | {
122 | const string KEY_TEXT = "(Wayback ";
123 | int start = title.IndexOf(KEY_TEXT) + KEY_TEXT.Length;
124 | int end = title.IndexOf(')', start);
125 |
126 | var dateStr = title.Substring(start, end - start);
127 |
128 | return DateOnly.ParseExact(dateStr, "yyyy-MM-dd");
129 | }
130 |
131 | [StackTraceHidden]
132 | private static XAttribute GetAttributeByName(XElement element, string name)
133 | => element.Attribute(name) ?? throw new ArgumentException($"{element.Name.LocalName} does not contain attribute \"{name}\"");
134 |
135 | [StackTraceHidden]
136 | private static XElement GetElementByName(XElement element, XNamespace ns, string name)
137 | => element.Element(ns + name) ?? throw new ArgumentException($"Layer does not contain element \"{name}\"");
138 | }
139 |
--------------------------------------------------------------------------------
/src/LibEsri/LibEsri.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0
5 | enable
6 | enable
7 | embedded
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/LibEsri/WayBack.cs:
--------------------------------------------------------------------------------
1 | using LibEsri.Geometry;
2 | using LibMapCommon;
3 | using LibMapCommon.Geometry;
4 | using System.Text;
5 | using System.Text.Json.Nodes;
6 |
7 | namespace LibEsri;
8 |
9 | public class WayBack
10 | {
11 | private const string WayBackUrl = "https://wayback.maptiles.arcgis.com/arcgis/rest/services/world_imagery/mapserver/wmts/1.0.0/wmtscapabilities.xml";
12 | private readonly CachedHttpClient HttpClient;
13 | private Dictionary Capabilities { get; }
14 | public IReadOnlyCollection Layers => Capabilities.Values;
15 |
16 | private WayBack(CachedHttpClient cacheHttpClient, Dictionary capabilities)
17 | {
18 | Capabilities = capabilities;
19 | HttpClient = cacheHttpClient;
20 | }
21 |
22 | public static async Task CreateAsync(string? cacheDir)
23 | {
24 | var cacheDirInfo = cacheDir is null ? null : new DirectoryInfo(cacheDir);
25 | cacheDirInfo?.Create();
26 |
27 | var cachedHttpClient = new CachedHttpClient(cacheDirInfo);
28 |
29 | var stream = await cachedHttpClient.GetStreamAsync(WayBackUrl);
30 | var caps = await LibEsri.Capabilities.LoadAsync(stream) ?? throw new Exception();
31 |
32 | return new WayBack(cachedHttpClient, caps.Layers.ToDictionary(l => l.ID));
33 | }
34 |
35 | public async Task GetDateAsync(Layer layer, EsriTile tile)
36 | {
37 | var metadataUrl = layer.GetPointQueryUrl(tile);
38 |
39 | try
40 | {
41 | var ss = await DownloadJsonAsync(metadataUrl);
42 |
43 | var date = ss?["features"]?[0]?["attributes"]?["SRC_DATE2"]?.GetValue();
44 |
45 | if (date is long dateNum)
46 | return DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(dateNum).DateTime);
47 | }
48 | catch { }
49 |
50 | return layer.Date;
51 | }
52 |
53 | public async Task GetDateRegionsAsync(Layer layer, WebMercatorPoly region, int zoom)
54 | {
55 | var metadataUrl = layer.GetEnvelopeQueryUrl(region, zoom);
56 |
57 | try
58 | {
59 | var ss = await DownloadJsonAsync(metadataUrl);
60 |
61 | if (ss?["features"]?.AsArray().ToDatedRegions(layer, region).ToArray() is not DatedRegion[] regions)
62 | return Array.Empty();
63 |
64 | //consolidate duplicate dates
65 | Dictionary set = new();
66 | foreach (var r in regions)
67 | {
68 | if (set.TryGetValue(r.Date, out var dr))
69 | {
70 | var arr1 = dr.Rings;
71 | Array.Resize(ref arr1, arr1.Length + r.Rings.Length);
72 | Array.Copy(r.Rings, 0, arr1, dr.Rings.Length, r.Rings.Length);
73 | set[r.Date] = new DatedRegion(r.Date, arr1);
74 | }
75 | else
76 | set.Add(r.Date, r);
77 | }
78 |
79 | return set.Values.ToArray();
80 | }
81 | catch { }
82 | return Array.Empty();
83 | }
84 |
85 | public async Task DownloadTileAsync(Layer layer, EsriTile tile)
86 | {
87 | var url = layer.GetAssetUrl(tile);
88 | var bts = await HttpClient.GetByteArrayAsync(url);
89 | return bts;
90 | }
91 |
92 | public async Task GetNearestDatedTileAsync(EsriTile tile, DateOnly desiredDate)
93 | {
94 | DatedEsriTile? datedTile = null;
95 |
96 | await foreach (var dt in GetDatesAsync(tile))
97 | {
98 | datedTile ??= dt;
99 | if (dt.CaptureDate <= desiredDate)
100 | {
101 | var d1 = datedTile.CaptureDate.DayNumber - desiredDate.DayNumber;
102 | var d2 = desiredDate.DayNumber - dt.CaptureDate.DayNumber;
103 |
104 | if (d2 < d1)
105 | datedTile = dt;
106 |
107 | break;
108 | }
109 |
110 | datedTile = dt;
111 | }
112 | return datedTile;
113 | }
114 |
115 | public async IAsyncEnumerable GetDatesAsync(EsriTile tile)
116 | {
117 | int? skipUntil = null;
118 | DateOnly? lastDate = null;
119 | Layer? last = null;
120 |
121 | foreach (var (i, layer) in Capabilities)
122 | {
123 | if (skipUntil != null)
124 | {
125 | if (skipUntil == i)
126 | skipUntil = null;
127 | continue;
128 | }
129 |
130 | var url = layer.GetTileMapUrl(tile);
131 | var ss = await DownloadJsonAsync(url);
132 |
133 | Layer f;
134 | if (ss?["select"]?[0] is JsonValue v)
135 | {
136 | skipUntil = v.GetValue();
137 | f = Capabilities[skipUntil.Value];
138 | }
139 | else
140 | {
141 | f = Capabilities[i];
142 | }
143 |
144 | if (ss?["data"]?[0]?.GetValue() == 1)
145 | {
146 | var date = await GetDateAsync(f, tile);
147 | if (lastDate.HasValue && last != null && lastDate.Value != date)
148 | {
149 | //Only emit a layer once the actual tile date changes.
150 | //In this way, only the earliest version with unique imagery is emitted.
151 | yield return new DatedEsriTile(lastDate.Value, last, tile);
152 | }
153 | lastDate = date;
154 | last = f;
155 | }
156 | }
157 |
158 | if (lastDate.HasValue && last != null)
159 | yield return new DatedEsriTile(lastDate.Value, last, tile);
160 | }
161 |
162 | protected async Task DownloadJsonAsync(string url)
163 | => JsonNode.Parse(await HttpClient.GetByteArrayAsync(url));
164 | protected async Task DownloadStringAsync(string url)
165 | => Encoding.UTF8.GetString(await HttpClient.GetByteArrayAsync(url));
166 | }
167 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/DatedTile.cs:
--------------------------------------------------------------------------------
1 | using Keyhole;
2 |
3 | namespace LibGoogleEarth;
4 |
5 | ///
6 | /// Represents a Google Earth aerial image tile from a specific date
7 | ///
8 | public record DatedTile : IEarthAsset
9 | {
10 | private const string ROOT_URL = "https://khmdb.google.com/flatfile?db=tm&f1-{0}-i.{1}-{2}";
11 | private const string ROOT_URL_NO_PROVIDER = "https://kh.google.com/flatfile?f1-{0}-i.{1}";
12 | /// The covered by this image
13 | public KeyholeTile Tile { get; }
14 | /// The aerial image's epoch.
15 | public int Epoch { get; }
16 | public DateOnly Date { get; }
17 | ///
18 | /// The Google Earther image's provider number
19 | ///
20 | public int Provider { get; }
21 | /// Url to the encrypted aerial image.
22 | public string AssetUrl { get; }
23 |
24 | public bool Compressed => false;
25 |
26 | internal DatedTile(KeyholeTile tile, QuadtreeImageryDatedTile datedTile)
27 | {
28 | Tile = tile;
29 | Provider = datedTile.Provider;
30 | Date = datedTile.DateOnly;
31 | Epoch = datedTile.DatedTileEpoch;
32 |
33 | AssetUrl = string.Format(ROOT_URL, tile.Path, Epoch, datedTile.Date.ToString("x"));
34 | }
35 |
36 | internal DatedTile(KeyholeTile tile, DateOnly tileDate, QuadtreeLayer imageryLayer)
37 | {
38 | Tile = tile;
39 | Provider = 0;
40 | Date = tileDate;
41 | Epoch = imageryLayer.LayerEpoch;
42 |
43 | AssetUrl = string.Format(ROOT_URL_NO_PROVIDER, tile.Path, Epoch);
44 | }
45 |
46 | public byte[] Decode(byte[] bytes) => bytes;
47 | }
48 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/DbRoot.cs:
--------------------------------------------------------------------------------
1 | using Keyhole;
2 | using Keyhole.Dbroot;
3 | using LibMapCommon;
4 | using Microsoft.Extensions.Caching.Memory;
5 | using System.Diagnostics.CodeAnalysis;
6 | using System.IO.Compression;
7 | using System.Runtime.InteropServices;
8 |
9 | namespace LibGoogleEarth;
10 |
11 | public enum Database
12 | {
13 | Default,
14 | TimeMachine,
15 | Sky,
16 | Moon,
17 | Mars
18 | }
19 |
20 | ///
21 | /// A Google earth database instance
22 | ///
23 | public abstract class DbRoot
24 | {
25 | private readonly CachedHttpClient HttpClient;
26 | /// The keyhole DbRoot protocol buffer
27 | public DbRootProto DbRootBuffer { get; }
28 | /// The google earth database
29 | public abstract Database Database { get; }
30 | private ReadOnlyMemory EncryptionData { get; }
31 | private MemoryCache PacketCache { get; } = new MemoryCache(new MemoryCacheOptions());
32 |
33 | private static readonly TimeSpan CacheCompactInterval = TimeSpan.FromSeconds(2);
34 | private static readonly MemoryCacheEntryOptions Options = new() { SlidingExpiration = CacheCompactInterval };
35 | private DateTime LastCacheComact;
36 |
37 | protected DbRoot(CachedHttpClient cachedHttpClient, EncryptedDbRootProto dbRootEnc)
38 | {
39 | HttpClient = cachedHttpClient;
40 | EncryptionData = dbRootEnc.EncryptionData.Memory;
41 | var bts = dbRootEnc.DbrootData.ToByteArray();
42 | Decrypt(bts);
43 | DbRootBuffer = DbRootProto.Parser.ParseFrom(DecompressBuffer(bts));
44 | }
45 |
46 | ///
47 | /// Create a new instance of the Google Earth database
48 | ///
49 | /// path to the cache directory. (default is .\cache\
50 | /// A new instance of the Google Earth database
51 | public static async Task CreateAsync(Database database, string? cacheDir)
52 | {
53 | var url = database is Database.Default ? DefaultDbRoot.DatabaseUrl : NamedDbRoot.DatabaseUrl(database);
54 |
55 | var cacheDirInfo = cacheDir is null ? null : new DirectoryInfo(cacheDir);
56 | cacheDirInfo?.Create();
57 |
58 | var cachedHttpClient = new CachedHttpClient(cacheDirInfo);
59 |
60 | byte[] dbRootBts = await cachedHttpClient.GetBytesIfNewer(url);
61 |
62 | var proto = EncryptedDbRootProto.Parser.ParseFrom(dbRootBts);
63 |
64 | return database is Database.Default
65 | ? new DefaultDbRoot(cachedHttpClient, proto)
66 | : new NamedDbRoot(database, cachedHttpClient, proto);
67 | }
68 |
69 | ///
70 | /// Gets a for a specified
71 | ///
72 | /// The tile to get
73 | /// The 's
74 | public async Task GetNodeAsync(KeyholeTile tile)
75 | {
76 | var packet = await GetQuadtreePacketAsync(tile);
77 | return
78 | packet?.SparseQuadtreeNode?.SingleOrDefault(n => n.Index == tile.SubIndex)?.Node is IQuadtreeNode n
79 | ? new TileNode(tile, n)
80 | : null;
81 | }
82 |
83 |
84 | [return: NotNullIfNotNull(nameof(terrainTile))]
85 | public async Task GetEarthAssetAsync(IEarthAsset? terrainTile)
86 | {
87 | if (terrainTile is null)
88 | return default;
89 |
90 | var rawAsset = await DownloadBytesAsync(terrainTile.AssetUrl);
91 | if (terrainTile.Compressed)
92 | rawAsset = DecompressBuffer(rawAsset);
93 |
94 | return terrainTile.Decode(rawAsset);
95 | }
96 |
97 | ///
98 | /// Gets a which references a specified
99 | ///
100 | /// The tile to get
101 | /// The which references the
102 | private async Task GetQuadtreePacketAsync(KeyholeTile tile)
103 | {
104 | if ((DateTime.UtcNow - LastCacheComact) > CacheCompactInterval)
105 | {
106 | lock (PacketCache)
107 | {
108 | //0% will remove all expired entries and nothing else.
109 | PacketCache.Compact(0);
110 | LastCacheComact = DateTime.UtcNow;
111 | }
112 | }
113 | var packet = await GetRootCachedAsync();
114 |
115 | if (packet == null)
116 | return null;
117 |
118 | foreach (var path in tile.Indices)
119 | {
120 | packet = await GetChildCachedAsync(packet, path);
121 | if (packet == null)
122 | return null;
123 | }
124 | return packet;
125 | }
126 |
127 | private async Task GetRootCachedAsync()
128 | {
129 | return await PacketCache.GetOrCreateAsync(KeyholeTile.Root, loadRootPacketAsync, Options);
130 |
131 | async Task loadRootPacketAsync(ICacheEntry _)
132 | => await GetPacketAsync(KeyholeTile.Root, (int)DbRootBuffer.DatabaseVersion.QuadtreeVersion);
133 | }
134 |
135 | private async Task GetChildCachedAsync(IQuadtreePacket parentPacket, KeyholeTile path)
136 | {
137 | return await PacketCache.GetOrCreateAsync(path, loadChildPacketAsync, Options);
138 |
139 | async Task loadChildPacketAsync(ICacheEntry _)
140 | {
141 | var childNode
142 | = parentPacket.SparseQuadtreeNode
143 | .Where(n => n.Node.CacheNodeEpoch != 0)
144 | .SingleOrDefault(n => n.Index == path.SubIndex)?.Node;
145 |
146 | if (childNode is null) return null;
147 |
148 | var childPacket = await GetPacketAsync(path, childNode.CacheNodeEpoch);
149 | return childPacket;
150 | }
151 | }
152 |
153 | protected abstract Task GetPacketAsync(KeyholeTile path, int epoch);
154 |
155 | ///
156 | /// Download, decrypt and cache a file from Google Earth.
157 | ///
158 | /// The Google Earth asset Url
159 | /// The decrypted asset's bytes
160 | protected Task DownloadBytesAsync(string url)
161 | => HttpClient.GetByteArrayAsync(url, Decrypt);
162 |
163 | private void Decrypt(Span cipherText)
164 | => Encode(EncryptionData.Span, cipherText);
165 |
166 | private static void Encode(ReadOnlySpan key, Span cipherText)
167 | {
168 | int off = 16;
169 | for (int j = 0; j < cipherText.Length; j++)
170 | {
171 | cipherText[j] ^= key[off++];
172 |
173 | if ((off & 7) == 0) off += 16;
174 | if (off >= key.Length) off = (off + 8) % 24;
175 | }
176 | }
177 |
178 | protected static byte[] DecompressBuffer(byte[] compressedPacket)
179 | {
180 | const int kPacketCompressHdrSize = 8;
181 |
182 | if (!tryGetDecompressBufferSize(compressedPacket, out var decompSz))
183 | throw new InvalidDataException("Failed to determine packet size.");
184 |
185 | var decompressed = GC.AllocateUninitializedArray(decompSz);
186 | using var compressedStream = new MemoryStream(compressedPacket[kPacketCompressHdrSize..], writable: false);
187 |
188 | using (var outputStream = new MemoryStream(decompressed))
189 | {
190 | using var decompressor = new ZLibStream(compressedStream, CompressionMode.Decompress);
191 | decompressor.CopyTo(outputStream);
192 | }
193 |
194 | return decompressed;
195 |
196 | static bool tryGetDecompressBufferSize(ReadOnlySpan buff, out int decompSz)
197 | {
198 | const uint kPktMagic = 0x7468deadu;
199 | const uint kPktMagicSwap = 0xadde6874u;
200 |
201 | var intBuf = MemoryMarshal.Cast(buff);
202 |
203 | if (buff.Length >= kPacketCompressHdrSize)
204 | {
205 | if (intBuf[0] == kPktMagic)
206 | {
207 | decompSz = (int)intBuf[1];
208 | return true;
209 | }
210 | else if (intBuf[0] == kPktMagicSwap)
211 | {
212 | decompSz = (int)System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(intBuf[1]);
213 | return true;
214 | }
215 | }
216 |
217 | decompSz = 0;
218 | return false;
219 | }
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/DefaultDbRoot.cs:
--------------------------------------------------------------------------------
1 | using Keyhole;
2 | using Keyhole.Dbroot;
3 | using LibMapCommon;
4 |
5 | namespace LibGoogleEarth;
6 |
7 | internal class DefaultDbRoot : DbRoot
8 | {
9 | public override Database Database => Database.Default;
10 | public static string DatabaseUrl => "https://khmdb.google.com/dbRoot.v5?&hl=en&gl=us&output=proto";
11 |
12 | internal DefaultDbRoot(CachedHttpClient cachedHttpClient, EncryptedDbRootProto dbRootEnc)
13 | : base(cachedHttpClient, dbRootEnc) { }
14 |
15 | protected override async Task GetPacketAsync(KeyholeTile tile, int epoch)
16 | {
17 | const string QP2 = "https://kh.google.com/flatfile?q2-{0}-q.{1}";
18 |
19 | var url = string.Format(QP2, tile.Path, epoch);
20 | byte[] compressedPacket = await DownloadBytesAsync(url);
21 | byte[] decompressedPacket = DecompressBuffer(compressedPacket);
22 |
23 | return KhQuadTreePacket16.ParseFrom(decompressedPacket, tile.IsRoot);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/IEarthAsset.cs:
--------------------------------------------------------------------------------
1 | namespace LibGoogleEarth;
2 |
3 | public interface IEarthAsset
4 | {
5 | bool Compressed { get; }
6 | string AssetUrl { get; }
7 | T Decode(byte[] bytes);
8 | }
9 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/IQuadtreeChannel.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public interface IQuadtreeChannel
4 | {
5 | int Type { get; }
6 | int ChannelEpoch { get; }
7 | }
8 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/IQuadtreeLayer.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public interface IQuadtreeLayer
4 | {
5 | QuadtreeLayer.Types.LayerType Type { get; }
6 | int LayerEpoch { get; }
7 | public int Provider { get; }
8 | }
9 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/IQuadtreeNode.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public interface IQuadtreeNode
4 | {
5 | int CacheNodeEpoch { get; }
6 | IReadOnlyList Layer { get; }
7 | IReadOnlyList Channel { get; }
8 | }
9 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/IQuadtreePacket.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public interface IQuadtreePacket
4 | {
5 | int PacketEpoch { get; }
6 | IReadOnlyList SparseQuadtreeNode { get; }
7 | }
8 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/ISparseQuadtreeNode.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public interface ISparseQuadtreeNode
4 | {
5 | int Index { get; }
6 | IQuadtreeNode Node { get; }
7 | }
8 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadTreeBTG.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.InteropServices;
2 |
3 | namespace Keyhole;
4 |
5 | [StructLayout(LayoutKind.Sequential, Size = 2, Pack = 2)]
6 | internal readonly record struct KhQuadTreeBTG
7 | {
8 | private static readonly byte[] bytemaskBTG = {
9 | 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80
10 | };
11 |
12 | private readonly byte children;
13 |
14 | public bool GetBit(int bit) { return (children & bytemaskBTG[bit]) != 0; }
15 |
16 | public bool Child0 => GetBit(0);
17 | public bool Child1 => GetBit(1);
18 | public bool Child2 => GetBit(2);
19 | public bool Child3 => GetBit(3);
20 |
21 | // CacheNodeBit indicates a node on last level.
22 | // client does not process children info for these,
23 | // since we don't actually have info for the children.
24 | // As a result, no need to set any of the children bits for
25 | // cache nodes, since client will simply disregard them.
26 | public bool HasCacheNode => GetBit(4);
27 |
28 | // Set if this node contains vector data.
29 | public bool HasDrawable => GetBit(5);
30 |
31 | // Set if this node contains image data.
32 | public bool HasImage => GetBit(6);
33 |
34 | // Set if this node contains terrain data.
35 | public bool HasTerrain => GetBit(7);
36 | }
37 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadTreePacket16.cs:
--------------------------------------------------------------------------------
1 | using LibGoogleEarth;
2 | using System.Runtime.InteropServices;
3 |
4 | namespace Keyhole;
5 |
6 | internal class KhQuadTreePacket16 : IQuadtreePacket
7 | {
8 | public int PacketEpoch { get; }
9 | public IReadOnlyList SparseQuadtreeNode => sparseNodes;
10 |
11 | private readonly KhSparseQuadtreeNode[] sparseNodes;
12 |
13 | private KhQuadTreePacket16(KhQuadTreePacketHeader header)
14 | {
15 | PacketEpoch = header.Version;
16 | sparseNodes = GC.AllocateUninitializedArray(header.NumInstances);
17 | }
18 |
19 | public static KhQuadTreePacket16 ParseFrom(Span bytes, bool isRoot)
20 | {
21 | var header = KhQuadTreePacketHeader.ParseFrom(bytes);
22 | var p = new KhQuadTreePacket16(header);
23 |
24 | var quanta = MemoryMarshal.Cast(bytes[KhQuadTreePacketHeader.HEADER_SIZE..header.DataBufferOffset]);
25 | var channels = MemoryMarshal.Cast(bytes[header.DataBufferOffset..]);
26 |
27 | Traverse(quanta, channels, p.sparseNodes, 0, "", isRoot);
28 |
29 | return p;
30 | }
31 |
32 | private static int Traverse(Span quanta, Span channels, KhSparseQuadtreeNode[] collector, int node_index, string qt_path, bool isRoot)
33 | {
34 | if (node_index >= collector.Length)
35 | return node_index;
36 |
37 | var q = quanta[node_index];
38 |
39 | var channelTypes = channels.Slice(q.type_offset / sizeof(short), q.num_channels).ToArray();
40 | var channelVersions = channels.Slice(q.version_offset / sizeof(short), q.num_channels).ToArray();
41 |
42 | var subIndex
43 | = isRoot ? Util.GetRootSubIndex("0" + qt_path)
44 | : node_index > 0 ? Util.GetTreeSubIndex(qt_path)
45 | : 0;
46 |
47 | collector[node_index] = new KhSparseQuadtreeNode(subIndex, new KhQuadtreeNode(q, channelTypes, channelVersions));
48 |
49 | for (int i = 0; i < 4; i++)
50 | {
51 | if (q.children.GetBit(i))
52 | {
53 | var new_qt_path = qt_path + i.ToString();
54 | node_index = Traverse(quanta, channels, collector, node_index + 1, new_qt_path, isRoot);
55 | }
56 | }
57 | return node_index;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadTreePacketHeader.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.InteropServices;
2 |
3 | namespace Keyhole;
4 |
5 | [StructLayout(LayoutKind.Sequential, Size = 32, Pack = 4)]
6 | public readonly record struct KhQuadTreePacketHeader
7 | {
8 | public const int HEADER_SIZE = 32;
9 | private const uint kKeyholeMagicId = 32301;
10 | public readonly uint MagicId;
11 | public readonly uint DataTypeId;
12 | public readonly int Version;
13 | public readonly int NumInstances;
14 | public readonly int DataInstanceSize;
15 | public readonly int DataBufferOffset;
16 | public readonly int DataBufferSize;
17 | public readonly int MetaBufferSize;
18 |
19 | public static KhQuadTreePacketHeader ParseFrom(Span bytes)
20 | {
21 | if (bytes.Length < sizeof(int) * 8)
22 | throw new ArgumentException("buffer is too small", nameof(bytes));
23 |
24 | var h = MemoryMarshal.Cast(bytes)[0];
25 |
26 | if (h.MagicId != kKeyholeMagicId)
27 | throw new InvalidDataException($"invalid magic_id: {h.MagicId}");
28 |
29 | if (h.NumInstances != 0 && h.DataBufferOffset != 32 + h.NumInstances * h.DataInstanceSize)
30 | throw new InvalidDataException("invalid data_buffer_offset");
31 |
32 | return h;
33 |
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadTreeQuantum16.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.InteropServices;
2 |
3 | namespace Keyhole;
4 |
5 | [StructLayout(LayoutKind.Sequential, Pack = 1)]
6 | internal readonly record struct KhQuadTreeQuantum16
7 | {
8 | private const int kImageNeighborCount = 8;
9 | private const int kSerialSize = 32; // size when serialized
10 | public readonly KhQuadTreeBTG children;
11 |
12 | public readonly short cnode_version; // cachenode version
13 | public readonly short image_version;
14 | public readonly short terrain_version;
15 |
16 | public readonly short num_channels;
17 | private readonly ushort junk16;
18 | internal readonly int type_offset;
19 | internal readonly int version_offset;
20 |
21 |
22 | internal readonly long image_neighbors;
23 |
24 |
25 | // Data provider info.
26 | // Terrain data provider does not seem to be used.
27 | public readonly byte image_data_provider;
28 | public readonly byte terrain_data_provider;
29 | private readonly ushort junk16_2;
30 | }
31 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadtreeChannel.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | internal class KhQuadtreeChannel : IQuadtreeChannel
4 | {
5 | public int Type { get; }
6 | public int ChannelEpoch { get; }
7 | public KhQuadtreeChannel(int type, int channelEpoch)
8 | {
9 | Type = type;
10 | ChannelEpoch = channelEpoch;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadtreeLayer.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | internal class KhQuadtreeLayer : IQuadtreeLayer
4 | {
5 | public QuadtreeLayer.Types.LayerType Type { get; }
6 | public int LayerEpoch { get; }
7 | public int Provider { get; }
8 | public KhQuadtreeLayer(QuadtreeLayer.Types.LayerType type, int layerEpoch, int provider)
9 | {
10 | Type = type;
11 | LayerEpoch = layerEpoch;
12 | Provider = provider;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhQuadtreeNode.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | internal class KhQuadtreeNode : IQuadtreeNode
4 | {
5 | public KhQuadTreeBTG Children { get; }
6 | public int CacheNodeEpoch { get; }
7 | IReadOnlyList IQuadtreeNode.Layer => Layers;
8 | IReadOnlyList IQuadtreeNode.Channel => Channels;
9 |
10 | private readonly List Layers;
11 | public readonly KhQuadtreeChannel[] Channels;
12 |
13 | public KhQuadtreeNode(KhQuadTreeQuantum16 khQuadTreeQuantum16, short[] channelTypes, short[] channelVersions)
14 | {
15 | ArgumentNullException.ThrowIfNull(channelTypes, nameof(channelTypes));
16 | ArgumentNullException.ThrowIfNull(channelVersions, nameof(channelVersions));
17 | ArgumentOutOfRangeException.ThrowIfNotEqual(channelTypes.Length, khQuadTreeQuantum16.num_channels, nameof(channelTypes));
18 | ArgumentOutOfRangeException.ThrowIfNotEqual(channelVersions.Length, khQuadTreeQuantum16.num_channels, nameof(channelVersions));
19 |
20 | Children = khQuadTreeQuantum16.children;
21 | CacheNodeEpoch = khQuadTreeQuantum16.cnode_version;
22 |
23 | Channels = new KhQuadtreeChannel[khQuadTreeQuantum16.num_channels];
24 | for (int i = 0; i < Channels.Length; i++)
25 | Channels[i] = new KhQuadtreeChannel(channelTypes[i], channelVersions[i]);
26 |
27 | int layerCount = 0;
28 | if (Children.HasTerrain)
29 | layerCount++;
30 | if (Children.HasDrawable)
31 | layerCount++;
32 | if (Children.HasImage)
33 | layerCount++;
34 |
35 | Layers = new(layerCount);
36 |
37 | if (Children.HasImage)
38 | Layers.Add(new KhQuadtreeLayer
39 | (
40 | QuadtreeLayer.Types.LayerType.Imagery,
41 | khQuadTreeQuantum16.image_version,
42 | khQuadTreeQuantum16.image_data_provider
43 | ));
44 | if (Children.HasTerrain)
45 | Layers.Add(new KhQuadtreeLayer
46 | (
47 | QuadtreeLayer.Types.LayerType.Terrain,
48 | khQuadTreeQuantum16.terrain_version,
49 | khQuadTreeQuantum16.terrain_data_provider
50 | ));
51 | if (Children.HasDrawable)
52 | Layers.Add(new KhQuadtreeLayer(QuadtreeLayer.Types.LayerType.Vector, 0, 0));
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/KhSparseQuadtreeNode.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | internal record KhSparseQuadtreeNode : ISparseQuadtreeNode
4 | {
5 | public int Index { get; }
6 | public KhQuadtreeNode Node { get; }
7 | IQuadtreeNode ISparseQuadtreeNode.Node => Node;
8 | public KhSparseQuadtreeNode(int subIndex, KhQuadtreeNode node)
9 | {
10 | Index = subIndex;
11 | Node = node;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/QuadtreeChannel.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public partial class QuadtreeChannel : IQuadtreeChannel { }
4 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/QuadtreeImageryDatedTile.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public partial class QuadtreeImageryDatedTile
4 | {
5 | private static int ToJpegCommentDate(DateOnly date)
6 | => ((date.Year & 0x7FF) << 9) | ((date.Month & 0xf) << 5) | (date.Day & 0x1f);
7 | private static DateOnly GetDate(QuadtreeImageryDatedTile datedTile)
8 | => new(datedTile.Date >> 9, (datedTile.Date >> 5) & 0xf, datedTile.Date & 0x1f);
9 | public DateOnly DateOnly => GetDate(this);
10 | }
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/QuadtreeLayer.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public partial class QuadtreeLayer : IQuadtreeLayer { }
4 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/QuadtreeNode.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public partial class QuadtreeNode : IQuadtreeNode
4 | {
5 | IReadOnlyList IQuadtreeNode.Layer => Layer;
6 | IReadOnlyList IQuadtreeNode.Channel => Channel;
7 | }
8 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Keyhole/QuadtreePacket.cs:
--------------------------------------------------------------------------------
1 | namespace Keyhole;
2 |
3 | public partial class QuadtreePacket : IQuadtreePacket
4 | {
5 | IReadOnlyList IQuadtreePacket.SparseQuadtreeNode => SparseQuadtreeNode;
6 |
7 | public partial class Types
8 | {
9 | public partial class SparseQuadtreeNode : ISparseQuadtreeNode
10 | {
11 | IQuadtreeNode ISparseQuadtreeNode.Node => Node;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/KeyholeTile.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon;
2 |
3 | namespace LibGoogleEarth;
4 |
5 | ///
6 | /// A square tile on earth's surface at a particular zoom level.
7 | ///
8 | public class KeyholeTile : ITile
9 | {
10 | /// The quadtree path string
11 | public string Path { get; }
12 | /// The subindex order of this path in the index packet.
13 | public int SubIndex { get; }
14 | /// Indicates if this instances represents the root quadtree node.
15 | public bool IsRoot => Path == Root.Path;
16 | /// Enumerates all quad tree indices after the root node.
17 | public IEnumerable Indices => EnumerateIndices();
18 |
19 | /*
20 | c0 c1
21 | |-----|-----|
22 | r1 | 3 | 2 |
23 | |-----|-----|
24 | r0 | 0 | 1 |
25 | |-----|-----|
26 | */
27 | public int Level { get; }
28 | /// The number of rows from the bottom-most (south-most) edge of the map.
29 | public int Row { get; }
30 | public int Column { get; }
31 | /// The roo quadtree node
32 | public static readonly KeyholeTile Root = new("0");
33 | public const int MaxLevel = 30;
34 | private const int SUBINDEX_MAX_SZ = 4;
35 |
36 | #region Constructors
37 | ///
38 | /// Initializes a new instance of a from a quadtree path string
39 | ///
40 | /// The rooted quadtree path string.
41 | public KeyholeTile(string quadTreePath)
42 | {
43 | Util.ValidateQuadTreePath(quadTreePath);
44 |
45 | Path = quadTreePath;
46 | SubIndex = GetSubIndex(Path);
47 |
48 | for (int i = 0; i < quadTreePath.Length; i++)
49 | {
50 | var cell = quadTreePath[i] & 3;
51 | int row = cell >> 1;
52 | int col = row ^ (cell & 1);
53 |
54 | Row = (Row << 1) | row;
55 | Column = (Column << 1) | col;
56 | }
57 | Level = quadTreePath.Length - 1;
58 | }
59 |
60 | ///
61 | /// Initializes a new instance of a by row, column, and zoom level
62 | ///
63 | /// The row containing the
64 | /// The column containing the
65 | /// The 's zoom level
66 | public KeyholeTile(int rowIndex, int colIndex, int level)
67 | {
68 | var numTiles = LibMapCommon.Util.ValidateLevel(level, MaxLevel);
69 | ArgumentOutOfRangeException.ThrowIfNegative(rowIndex, nameof(rowIndex));
70 | ArgumentOutOfRangeException.ThrowIfNegative(colIndex, nameof(colIndex));
71 | ArgumentOutOfRangeException.ThrowIfGreaterThan(rowIndex, numTiles - 1, nameof(rowIndex));
72 | ArgumentOutOfRangeException.ThrowIfGreaterThan(colIndex, numTiles - 1, nameof(colIndex));
73 |
74 | Row = rowIndex;
75 | Column = colIndex;
76 | Level = level;
77 |
78 | var chars = new char[level + 1];
79 | for (int i = level; i >= 0; i--)
80 | {
81 | var row = rowIndex & 1;
82 | var col = colIndex & 1;
83 | rowIndex >>= 1;
84 | colIndex >>= 1;
85 |
86 | chars[i] = (char)(row << 1 | (row ^ col) | 0x30);
87 | }
88 |
89 | Path = new string(chars);
90 | Util.ValidateQuadTreePath(Path);
91 | SubIndex = GetSubIndex(Path);
92 | }
93 | #endregion
94 |
95 | public static KeyholeTile GetTile(Wgs1984 coordinate, int level)
96 | {
97 | return new(Util.LatLongToRowCol(coordinate.Latitude, level), Util.LatLongToRowCol(coordinate.Longitude, level), level);
98 | }
99 |
100 | public static KeyholeTile GetMinimumCorner(Wgs1984 c1, Wgs1984 c2, int level)
101 | {
102 | var lowerMost = Math.Min(c1.Latitude, c2.Latitude);
103 | var leftMost = Math.Min(c1.Longitude, c2.Longitude);
104 | return GetTile(new Wgs1984(lowerMost, leftMost), level);
105 | }
106 |
107 | public static KeyholeTile Create(int row, int col, int level)
108 | => new KeyholeTile(row, col, level);
109 |
110 | #region Coordinates
111 | private double RowColToLatLong(double rowCol)
112 | => Util.RowColToLatLong(Level, rowCol);
113 |
114 | /// The lower-left (southwest) of this
115 | public Wgs1984 LowerLeft => new(RowColToLatLong(Row), RowColToLatLong(Column));
116 | /// The lower-right (southeast) of this
117 | public Wgs1984 LowerRight => new(RowColToLatLong(Row), RowColToLatLong(Column + 1));
118 | /// The upper-left (northwest) of this
119 | public Wgs1984 UpperLeft => new(RowColToLatLong(Row + 1), RowColToLatLong(Column));
120 | /// The upper-right (northeast) of this
121 | public Wgs1984 UpperRight => new(RowColToLatLong(Row + 1), RowColToLatLong(Column + 1));
122 | /// of the center of this
123 | public Wgs1984 Center => new(RowColToLatLong(Row + 0.5), RowColToLatLong(Column + 0.5));
124 | #endregion
125 |
126 | #region Helpers
127 | private IEnumerable EnumerateIndices()
128 | {
129 | for (int end = SUBINDEX_MAX_SZ; end < Path.Length; end += SUBINDEX_MAX_SZ)
130 | yield return new KeyholeTile(Path[..end]);
131 | }
132 | public override string ToString() => Path;
133 | public override int GetHashCode() => Path.GetHashCode();
134 | public override bool Equals(object? obj) => obj is KeyholeTile other && other.Path == Path;
135 |
136 | #endregion
137 |
138 | #region Subindex Calculation
139 |
140 | // Nodes have two numbering schemes:
141 | //
142 | // 1) "Subindex". This numbering starts at the top of the tree
143 | // and goes left-to-right across each level, like this:
144 | //
145 | // 0
146 | // / \ .
147 | // 1 86 171 256
148 | // / \ .
149 | // 2 3 4 5 ...
150 | // / \ .
151 | // 6 7 8 9 ...
152 | //
153 | // Notice that the second row is weird in that it's not left-to-right
154 | // order. HOWEVER, the root node in Keyhole is special in that it
155 | // doesn't have this weird ordering. It looks like this:
156 | //
157 | // 0
158 | // / \ .
159 | // 1 2 3 4
160 | // / \ .
161 | // 5 6 7 8 ...
162 | // / \ .
163 | // 21 22 23 24 ...
164 | //
165 | // The mangling of the second row is controlled by a parameter to the
166 | // constructor.
167 |
168 | private static int GetSubIndex(string quadTreePath)
169 | {
170 | return quadTreePath.Length <= SUBINDEX_MAX_SZ
171 | ? Util.GetRootSubIndex(quadTreePath)
172 | : Util.GetTreeSubIndex(getSubindexPath());
173 |
174 | string getSubindexPath()
175 | => quadTreePath.Substring((quadTreePath.Length - 1) / SUBINDEX_MAX_SZ * SUBINDEX_MAX_SZ);
176 | }
177 | #endregion
178 |
179 | }
180 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/LibGoogleEarth.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0
5 | enable
6 | enable
7 | embedded
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/NamedDbRoot.cs:
--------------------------------------------------------------------------------
1 | using Keyhole;
2 | using Keyhole.Dbroot;
3 | using LibMapCommon;
4 |
5 | namespace LibGoogleEarth;
6 |
7 | internal class NamedDbRoot : DbRoot
8 | {
9 | public override Database Database => _database;
10 | private readonly Database _database;
11 |
12 | internal NamedDbRoot(Database database, CachedHttpClient cachedHttpClient, EncryptedDbRootProto dbRootEnc)
13 | : base(cachedHttpClient, dbRootEnc)
14 | {
15 | _database = database;
16 | }
17 |
18 | protected override async Task GetPacketAsync(KeyholeTile tile, int epoch)
19 | {
20 | const string QP2_EXTENDED = "https://khmdb.google.com/flatfile?db={0}&qp-{1}-q.{2}";
21 |
22 | var url = string.Format(QP2_EXTENDED, DatabaseString(Database), tile.Path, epoch);
23 | byte[] compressedPacket = await DownloadBytesAsync(url);
24 | byte[] decompressedPacket = DecompressBuffer(compressedPacket);
25 |
26 | return QuadtreePacket.Parser.ParseFrom(decompressedPacket);
27 | }
28 |
29 | public static string DatabaseUrl(Database database)
30 | {
31 | var databaseString = DatabaseString(database);
32 | return $"https://khmdb.google.com/dbRoot.v5?db={databaseString}&hl=en&gl=us&output=proto";
33 | }
34 |
35 | private static string DatabaseString(Database database) => database switch
36 | {
37 | Database.Mars => "mars",
38 | Database.Moon => "moon",
39 | Database.Sky => "sky",
40 | Database.TimeMachine => "tm",
41 | _ => throw new ArgumentException(nameof(database))
42 | };
43 | }
44 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/TerrainTile.cs:
--------------------------------------------------------------------------------
1 | using Keyhole;
2 | using LibGoogleEarth.WORKINGPROGRESS;
3 |
4 | namespace LibGoogleEarth;
5 |
6 | public class TerrainTile : IEarthAsset
7 | {
8 | private const string ROOT_URL_NO_PROVIDER = "https://kh.google.com/flatfile?f1c-{0}-t.{1}";
9 | public KeyholeTile Tile { get; }
10 | /// The aerial image's epoch.
11 | public int Epoch { get; }
12 | ///
13 | /// The Google Earther image's provider number
14 | ///
15 | public int Provider { get; }
16 | /// Url to the encrypted aerial image.
17 | public string AssetUrl { get; }
18 |
19 | public bool Compressed => true;
20 |
21 | internal TerrainTile(KeyholeTile tile, IQuadtreeLayer datedTile)
22 | {
23 | Tile = tile;
24 | Provider = datedTile.Provider;
25 | Epoch = datedTile.LayerEpoch;
26 |
27 | AssetUrl = string.Format(ROOT_URL_NO_PROVIDER, tile.Path, Epoch);
28 | }
29 |
30 | public GridMesh[] Decode(byte[] bytes) => GridMesh.ParseAllMeshes(bytes);
31 | }
32 |
33 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/TileNode.cs:
--------------------------------------------------------------------------------
1 | using Keyhole;
2 |
3 | namespace LibGoogleEarth;
4 |
5 | ///
6 | /// Relates a to a
7 | ///
8 | public class TileNode
9 | {
10 | private const int MIN_JPEG_DATE = 545;
11 | /// The Google Earth quadtree node
12 | public IQuadtreeNode QuadtreeNode { get; }
13 | /// The associated with the
14 | public KeyholeTile Tile { get; }
15 |
16 | internal TileNode(KeyholeTile tile, IQuadtreeNode quadtreeNode)
17 | {
18 | QuadtreeNode = quadtreeNode;
19 | Tile = tile;
20 | }
21 |
22 | public bool HasTerrain()
23 | => QuadtreeNode.Layer.Any(l => l.Type is QuadtreeLayer.Types.LayerType.Terrain);
24 |
25 | public TerrainTile? GetTerrain()
26 | {
27 | var terrainLayer = QuadtreeNode.Layer.SingleOrDefault(l => l.Type is QuadtreeLayer.Types.LayerType.Terrain);
28 |
29 | return terrainLayer == null ? null
30 | : new TerrainTile(Tile, terrainLayer);
31 | }
32 |
33 |
34 | ///
35 | /// Determines whether this quadtree node has imagery available from a specific date.
36 | ///
37 | /// A specific date
38 | /// if the quadtree node has imagery available from the date; otherwise, .
39 | public bool HasDate(DateOnly dateOnly)
40 | => GetAllDatedTiles().Any(dt => dt.Date == dateOnly);
41 |
42 | ///
43 | /// Returns an enumerable collection of all s present in the
44 | ///
45 | public IEnumerable GetAllDatedTiles()
46 | {
47 | if (QuadtreeNode is not QuadtreeNode node)
48 | yield break;
49 |
50 | var datesLayer
51 | = node
52 | ?.Layer
53 | ?.FirstOrDefault(l => l.Type is QuadtreeLayer.Types.LayerType.ImageryHistory)
54 | ?.DatesLayer
55 | .DatedTile;
56 |
57 | if (datesLayer == null)
58 | yield break;
59 |
60 | foreach (var dt in datesLayer)
61 | {
62 | if (dt.Date <= MIN_JPEG_DATE)
63 | continue;
64 | else if (dt.Provider != 0)
65 | yield return new DatedTile(Tile, dt);
66 | //When Provider is zero, that tile's imagery is being used as the default and is in the Imagery layer.
67 | else if (node?.Layer?.FirstOrDefault(l => l.Type is QuadtreeLayer.Types.LayerType.Imagery) is QuadtreeLayer regImagery)
68 | yield return new DatedTile(Tile, dt.DateOnly, regImagery);
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/Util.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.Diagnostics.CodeAnalysis;
3 |
4 | namespace LibGoogleEarth;
5 |
6 | public static class Util
7 | {
8 | public static int GetTreeSubIndex(string quadTreePath)
9 | => GetRootSubIndex(quadTreePath) + (quadTreePath[0] - 0x30) * 85 + 1;
10 |
11 | public static int GetRootSubIndex(string quadTreePath)
12 | {
13 | const int SUBINDEX_MAX_SZ = 4;
14 | ValidatePathCharacters(quadTreePath);
15 | ArgumentOutOfRangeException.ThrowIfGreaterThan(quadTreePath.Length, SUBINDEX_MAX_SZ, nameof(quadTreePath));
16 |
17 | int subIndex = 0;
18 |
19 | for (int i = 1; i < quadTreePath.Length; i++)
20 | {
21 | subIndex *= SUBINDEX_MAX_SZ;
22 | subIndex += quadTreePath[i] - 0x30 + 1;
23 | }
24 | return subIndex;
25 | }
26 | public static int LatLongToRowCol(double latLong, int level)
27 | {
28 | int numTiles = LibMapCommon.Util.ValidateLevel(level, KeyholeTile.MaxLevel);
29 | int rowCol = (int)Math.Floor((latLong + 180) / 360 * numTiles);
30 | return Math.Min(rowCol, numTiles - 1);
31 | }
32 |
33 | public static double RowColToLatLong(int level, double rowCol)
34 | {
35 | int numTiles = LibMapCommon.Util.ValidateLevel(level, KeyholeTile.MaxLevel);
36 | ArgumentOutOfRangeException.ThrowIfNegative(rowCol, nameof(rowCol));
37 | ArgumentOutOfRangeException.ThrowIfGreaterThan(rowCol, numTiles, nameof(rowCol));
38 | return rowCol * 360d / numTiles - 180;
39 | }
40 |
41 | [StackTraceHidden]
42 | public static void ValidateQuadTreePath([NotNull] string? quadTreePath)
43 | {
44 | ValidatePathCharacters(quadTreePath);
45 | if (quadTreePath[0] != '0')
46 | throw new ArgumentException("All quadtree paths must begin with a '0'", nameof(quadTreePath));
47 | }
48 |
49 | [StackTraceHidden]
50 | public static void ValidatePathCharacters([NotNull] string? quadTreePath)
51 | {
52 | ArgumentException.ThrowIfNullOrEmpty(quadTreePath, nameof(quadTreePath));
53 | if (quadTreePath?.All(c => c is '0' or '1' or '2' or '3') is not true)
54 | throw new ArgumentException("Quad Tree Path can only contain the characters '0', '1', '2', and '3'", nameof(quadTreePath));
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/LibGoogleEarth/WORKINGPROGRESS/GridMesh.cs:
--------------------------------------------------------------------------------
1 | using System.Collections;
2 | using System.Diagnostics;
3 | using System.Runtime.InteropServices;
4 |
5 | namespace LibGoogleEarth.WORKINGPROGRESS;
6 |
7 | public class GridMesh : IEnumerable
8 | {
9 | private const int TileSize = 32;
10 |
11 | private const int SizeX = 17;
12 |
13 | private const int SizeY = 17;
14 |
15 | private const double khEarthMeanRadius = 6371010.0;
16 |
17 | private static readonly double NegativeElevationFactor = 0.0 - Math.Pow(2.0, 32.0);
18 |
19 | private const double negativeElevationThreshold = 1E-12;
20 |
21 | private const int kNegativeElevationExponentBias = 32;
22 |
23 | public int Level { get; }
24 |
25 | public int OriginColumn { get; }
26 |
27 | public int OriginRow { get; }
28 |
29 | public double?[] Elevations { get; }
30 |
31 | public MeshFace[] Faces { get; }
32 |
33 | public GridMesh(int level, int llColumn, int llRow, double?[] elevations, MeshFace[] faces)
34 | {
35 | Level = level;
36 | OriginColumn = llColumn;
37 | OriginRow = llRow;
38 | Elevations = elevations;
39 | Faces = faces;
40 | }
41 |
42 | public IEnumerator GetEnumerator()
43 | {
44 | int numPoints = Elevations.Count((e) => e.HasValue);
45 | int[] remap = new int[Elevations.Length];
46 | Coordinate3D[] coords = new Coordinate3D[numPoints];
47 | int coordNum = 0;
48 | double elev = default;
49 | for (int j = 0; j < Elevations.Length; j++)
50 | {
51 | double? num = Elevations[j];
52 | int num2;
53 | if (num.HasValue)
54 | {
55 | elev = num.GetValueOrDefault();
56 | num2 = 1;
57 | }
58 | else
59 | {
60 | num2 = 0;
61 | }
62 | if (num2 != 0)
63 | {
64 | int row = j / 33;
65 | int col = j % 33;
66 | double lon = Util.RowColToLatLong(Level, OriginColumn + col / 32.0);
67 | double lat = Util.RowColToLatLong(Level, OriginRow + row / 32.0);
68 | coords[coordNum] = new Coordinate3D(lat, lon, elev);
69 | remap[j] = coordNum++;
70 | }
71 | }
72 | for (int i = 0; i < Faces.Length; i++)
73 | {
74 | MeshFace f = Faces[i];
75 | yield return new Face3D(coords[remap[f.A]], coords[remap[f.B]], coords[remap[f.C]]);
76 | }
77 | }
78 |
79 |
80 | IEnumerator IEnumerable.GetEnumerator()
81 | {
82 | return GetEnumerator();
83 | }
84 |
85 | public static GridMesh[] ParseAllMeshes(Span bytes)
86 | {
87 | TerrainMesh.NativeMeshHeader[] headers = ReadAllMeshHeaders(bytes);
88 | int offset = 48;
89 | TerrainMesh.NativeMeshHeader[] quad = new TerrainMesh.NativeMeshHeader[4];
90 | GridMesh[] meshes = new GridMesh[5];
91 | for (int i = 0; i < 5; i++)
92 | {
93 | int start = i * 4;
94 | if (headers.Length < start + 4)
95 | {
96 | Array.Resize(ref meshes, i);
97 | break;
98 | }
99 | Array.Copy(headers, start, quad, 0, quad.Length);
100 | offset = ParseMeshPackage(offset, bytes, quad, out meshes[i]);
101 | }
102 | return meshes;
103 | }
104 |
105 | private static int ParseMeshPackage(int offset, Span bytes, TerrainMesh.NativeMeshHeader[] headers, out GridMesh mesh)
106 | {
107 | Debug.Assert(headers.Length == 4);
108 | double?[] elevations = new double?[1089];
109 | MeshFace[] faces = new MeshFace[headers.Sum((h) => h.num_faces)];
110 | Span facesSpan = faces;
111 | int packetLevel = headers[0].level;
112 | int numColsAtLevel = 1 << packetLevel;
113 | int ox = (int)double.Round((headers[0].ox + 1.0) * numColsAtLevel / 4.0);
114 | int oy = (int)double.Round((headers[0].oy + 1.0) * numColsAtLevel / 4.0);
115 | mesh = new GridMesh(packetLevel - 1, ox, oy, elevations, faces);
116 | int faceStart = 0;
117 | for (int q = 0; q < 4; q++)
118 | {
119 | int dataSize = headers[q].source_size - 48 + 4;
120 | int r = q / 2;
121 | int c = (q + r) % 2;
122 | ParseSingleMesh(c, r, bytes.Slice(offset, dataSize), headers[q], elevations, facesSpan.Slice(faceStart, headers[q].num_faces));
123 | faceStart += headers[q].num_faces;
124 | offset += dataSize + 48;
125 | }
126 | int notnull = elevations.Count((e) => e.HasValue);
127 | int numVerts = headers.Sum((h) => h.num_points);
128 | return offset;
129 | }
130 |
131 | private static void ParseSingleMesh(int col, int row, Span bytes, TerrainMesh.NativeMeshHeader header, double?[] elevationGrid, Span meshFaces)
132 | {
133 | int packetLevel = header.level;
134 | int numColsAtLevel = 1 << packetLevel;
135 | Span vertices = MemoryMarshal.Cast(bytes.Slice(0, header.num_points * 6));
136 | Span faces = MemoryMarshal.Cast(bytes.Slice(header.num_points * 6, header.num_faces * 6));
137 | int[] vertexRemap = new int[vertices.Length];
138 | for (int j = 0; j < vertices.Length; j++)
139 | {
140 | TerrainMesh.NativeMeshVertex v = vertices[j];
141 | double colFraction = v.X * header.dx * numColsAtLevel / 2.0;
142 | double rowFraction = v.Y * header.dy * numColsAtLevel / 2.0;
143 | int partialCol = (int)(16.0 * colFraction);
144 | int partialRow = (int)(16.0 * rowFraction);
145 | int c = partialCol + col * 16;
146 | int r = partialRow + row * 16;
147 | int tableIndex = r * 33 + c;
148 | double ele = ZtoElev(v.Z);
149 | if (elevationGrid[tableIndex].HasValue)
150 | {
151 | bool same = elevationGrid[tableIndex] == ele;
152 | }
153 | elevationGrid[tableIndex] = ele;
154 | vertexRemap[j] = tableIndex;
155 | }
156 | for (int i = 0; i < meshFaces.Length; i++)
157 | {
158 | meshFaces[i] = new MeshFace(vertexRemap[faces[i].A], vertexRemap[faces[i].B], vertexRemap[faces[i].C]);
159 | }
160 | }
161 |
162 | private static TerrainMesh.NativeMeshHeader[] ReadAllMeshHeaders(Span bytes)
163 | {
164 | int offset = 0;
165 | TerrainMesh.NativeMeshHeader[] nativeMeshHeaders = new TerrainMesh.NativeMeshHeader[20];
166 | for (int h = 0; h < nativeMeshHeaders.Length; h++)
167 | {
168 | TerrainMesh.NativeMeshHeader header = MemoryMarshal.AsRef(bytes.Slice(offset, 48));
169 | if (header.source_size == 0)
170 | {
171 | Array.Resize(ref nativeMeshHeaders, h);
172 | return nativeMeshHeaders;
173 | }
174 | nativeMeshHeaders[h] = header;
175 | offset += header.source_size + 4;
176 | }
177 | return nativeMeshHeaders;
178 | }
179 |
180 | private static double ZtoElev(float z)
181 | {
182 | double tmp_z = z;
183 | if (tmp_z != 0.0 && tmp_z < 1E-12)
184 | {
185 | tmp_z *= NegativeElevationFactor;
186 | }
187 | return tmp_z * 6371010.0;
188 | }
189 | }
190 |
191 | public readonly record struct Coordinate3D(double Latitude, double Longitude, double Elevation);
192 |
193 | public readonly record struct Face3D(Coordinate3D A, Coordinate3D B, Coordinate3D C);
194 |
195 | [StructLayout(LayoutKind.Sequential, Pack = 4)]
196 | public readonly record struct MeshFace(int A, int B, int C);
--------------------------------------------------------------------------------
/src/LibGoogleEarth/WORKINGPROGRESS/TerrainMesh.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.InteropServices;
2 |
3 | namespace LibGoogleEarth.WORKINGPROGRESS;
4 |
5 | public class TerrainMesh : IEnumerable
6 | {
7 | [StructLayout(LayoutKind.Sequential, Pack = 1)]
8 | private readonly record struct MeshVertex(int Column, int Row, double Elevation, byte PartialColumn, byte PartialRow)
9 | {
10 | private const double NumPartialsPerWhole = 16.0;
11 |
12 | public Coordinate3D ToCoordinate(int level)
13 | {
14 | return new Coordinate3D(Util.RowColToLatLong(level, Row + PartialRow / 16.0), Util.RowColToLatLong(level, Column + PartialColumn / 16.0), Elevation);
15 | }
16 | }
17 |
18 | [StructLayout(LayoutKind.Sequential, Pack = 4)]
19 | public struct NativeMeshHeader
20 | {
21 | public const int Size = 48;
22 | public readonly int source_size;
23 | public readonly double ox;
24 | public readonly double oy;
25 | public readonly double dx;
26 | public readonly double dy;
27 | public readonly int num_points;
28 | public readonly int num_faces;
29 | public readonly int level;
30 | }
31 |
32 | [StructLayout(LayoutKind.Sequential, Pack = 1)]
33 | public readonly record struct NativeMeshVertex(byte X, byte Y, float Z);
34 |
35 | [StructLayout(LayoutKind.Sequential, Pack = 2)]
36 | public readonly record struct NativeMeshFace(ushort A, ushort B, ushort C);
37 |
38 | private readonly MeshVertex[] MeshVertices;
39 |
40 | private readonly MeshFace[] MeshFaces;
41 |
42 | private const int TileSize = 32;
43 |
44 | private const int SizeX = 17;
45 |
46 | private const int SizeY = 17;
47 |
48 | private const double khEarthMeanRadius = 6371010.0;
49 |
50 | private static readonly double NegativeElevationFactor = 0.0 - Math.Pow(2.0, 32.0);
51 |
52 | private const double negativeElevationThreshold = 1E-12;
53 |
54 | private const int kNegativeElevationExponentBias = 32;
55 |
56 | public int Level { get; }
57 |
58 | public bool IsEmpty => Level == -1 && MeshVertices.Length == 0 && MeshFaces.Length == 0;
59 |
60 | public static TerrainMesh Empty => new TerrainMesh(-1, Array.Empty(), Array.Empty());
61 |
62 | private TerrainMesh(int level, MeshVertex[] vertices, MeshFace[] compactFaces)
63 | {
64 | Level = level;
65 | MeshVertices = vertices;
66 | MeshFaces = compactFaces;
67 | }
68 |
69 | public List GetVertices()
70 | {
71 | return MeshVertices.Select((mv) => mv.ToCoordinate(Level)).ToList();
72 | }
73 |
74 | public List GetFacesReferencingVertices()
75 | {
76 | return MeshFaces.ToList();
77 | }
78 |
79 | System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
80 | {
81 | return GetEnumerator();
82 | }
83 |
84 | public IEnumerator GetEnumerator()
85 | {
86 | return MeshFaces.Select((f) => new Face3D(MeshVertices[f.A].ToCoordinate(Level), MeshVertices[f.B].ToCoordinate(Level), MeshVertices[f.C].ToCoordinate(Level))).GetEnumerator();
87 | }
88 |
89 | public static TerrainMesh Combine(TerrainMesh meshA, TerrainMesh meshB)
90 | {
91 | return Combine(new TerrainMesh[2] { meshA, meshB });
92 | }
93 |
94 | public static TerrainMesh Combine(params TerrainMesh[] meshes)
95 | {
96 | return meshes == null || meshes.Length == 0 ? Empty : Combine(meshes.AsEnumerable());
97 | }
98 |
99 | public static TerrainMesh Combine(IEnumerable meshes)
100 | {
101 | TerrainMesh firstMesh = meshes.FirstOrDefault() ?? Empty;
102 | int? meshLevel = firstMesh.IsEmpty ? null : new int?(firstMesh.Level);
103 | int maxNumPoints = meshes.Sum((m) => m.MeshVertices.Length);
104 | int totalFaces = meshes.Sum((m) => m.MeshFaces.Length);
105 | MeshVertex[] NewVertices = GC.AllocateUninitializedArray(maxNumPoints);
106 | MeshFace[] NewFaces = GC.AllocateUninitializedArray(totalFaces);
107 | int numPoints = firstMesh.MeshVertices.Length;
108 | int numFaces = firstMesh.MeshFaces.Length;
109 | firstMesh.MeshVertices.CopyTo(NewVertices, 0);
110 | firstMesh.MeshFaces.CopyTo(NewFaces, 0);
111 | foreach (TerrainMesh mesh in meshes.Skip(1))
112 | {
113 | int level = mesh.Level;
114 | int valueOrDefault = meshLevel.GetValueOrDefault();
115 | int num;
116 | if (!meshLevel.HasValue)
117 | {
118 | valueOrDefault = mesh.Level;
119 | meshLevel = valueOrDefault;
120 | num = valueOrDefault;
121 | }
122 | else
123 | {
124 | num = valueOrDefault;
125 | }
126 | if (level != num)
127 | {
128 | throw new ArgumentException("Cannot merge meshes of different levels.", "meshes");
129 | }
130 | int[] remap = new int[mesh.MeshVertices.Length];
131 | for (int i = 0; i < mesh.MeshVertices.Length; i++)
132 | {
133 | MeshVertex v = mesh.MeshVertices[i];
134 | int pI = IndexOfVertex(NewVertices, v, numPoints);
135 | if (pI == -1)
136 | {
137 | NewVertices[numPoints] = v;
138 | remap[i] = numPoints++;
139 | }
140 | else
141 | {
142 | remap[i] = pI;
143 | }
144 | }
145 | MeshFace[] meshFaces = mesh.MeshFaces;
146 | for (int j = 0; j < meshFaces.Length; j++)
147 | {
148 | MeshFace f = meshFaces[j];
149 | MeshFace newFace = new MeshFace(remap[f.A], remap[f.B], remap[f.C]);
150 | NewFaces[numFaces++] = newFace;
151 | }
152 | }
153 | if (!meshLevel.HasValue)
154 | {
155 | throw new InvalidOperationException("Unable to determine mesh level");
156 | }
157 | Array.Resize(ref NewVertices, numPoints);
158 | return new TerrainMesh(meshLevel.Value, NewVertices, NewFaces);
159 | }
160 |
161 | private static int IndexOfVertex(MeshVertex[] vertices, MeshVertex value, int count)
162 | {
163 | for (int i = 0; i < count; i++)
164 | {
165 | if (vertices[i].Column == value.Column && vertices[i].Row == value.Row && vertices[i].PartialRow == value.PartialRow && vertices[i].PartialColumn == value.PartialColumn)
166 | {
167 | return i;
168 | }
169 | }
170 | return -1;
171 | }
172 |
173 | private static void ParseSingleMesh(int col, int row, Span bytes, NativeMeshHeader header, double[] elevationGrid, Span meshFaces)
174 | {
175 | int packetLevel = header.level;
176 | int numColsAtLevel = 1 << packetLevel;
177 | Span vertices = MemoryMarshal.Cast(bytes.Slice(0, header.num_points * 6));
178 | Span faces = MemoryMarshal.Cast(bytes.Slice(header.num_points * 6, header.num_faces * 6));
179 | int[] vertexRemap = new int[vertices.Length];
180 | for (int j = 0; j < vertices.Length; j++)
181 | {
182 | NativeMeshVertex v = vertices[j];
183 | double colFraction = v.X * header.dx * numColsAtLevel / 2.0;
184 | double rowFraction = v.Y * header.dy * numColsAtLevel / 2.0;
185 | int partialCol = (int)(16.0 * colFraction);
186 | int partialRow = (int)(16.0 * rowFraction);
187 | int c = partialCol + col * 16;
188 | int r = partialRow + row * 16;
189 | int tableIndex = r * 32 + c;
190 | elevationGrid[tableIndex] = ZtoElev(v.Z);
191 | vertexRemap[j] = tableIndex;
192 | }
193 | for (int i = 0; i < meshFaces.Length; i++)
194 | {
195 | meshFaces[i] = new MeshFace(vertexRemap[faces[i].A], vertexRemap[faces[i].B], vertexRemap[faces[i].C]);
196 | }
197 | }
198 |
199 | private static TerrainMesh ParseSingleMesh(Span bytes, NativeMeshHeader header)
200 | {
201 | int packetLevel = header.level;
202 | int numColsAtLevel = 1 << packetLevel;
203 | int ox = (int)double.Round((header.ox + 1.0) * numColsAtLevel / 2.0);
204 | int oy = (int)double.Round((header.oy + 1.0) * numColsAtLevel / 2.0);
205 | Span vertices = MemoryMarshal.Cast(bytes.Slice(0, header.num_points * 6));
206 | Span faces = MemoryMarshal.Cast(bytes.Slice(header.num_points * 6, header.num_faces * 6));
207 | MeshVertex[] points = new MeshVertex[vertices.Length];
208 | for (int j = 0; j < vertices.Length; j++)
209 | {
210 | NativeMeshVertex v = vertices[j];
211 | double colFraction = v.X * header.dx * numColsAtLevel / 2.0;
212 | double rowFraction = v.Y * header.dy * numColsAtLevel / 2.0;
213 | double partialCol = 16.0 * colFraction;
214 | double partialRow = 16.0 * rowFraction;
215 | points[j] = new MeshVertex(ox, oy, ZtoElev(v.Z), (byte)partialCol, (byte)partialRow);
216 | }
217 | MeshFace[] faces2 = new MeshFace[faces.Length];
218 | for (int i = 0; i < faces2.Length; i++)
219 | {
220 | faces2[i] = new MeshFace(faces[i].A, faces[i].B, faces[i].C);
221 | }
222 | return new TerrainMesh(packetLevel, points, faces2);
223 | }
224 |
225 | private static NativeMeshHeader[] ReadAllMeshHeaders(Span bytes)
226 | {
227 | int offset = 0;
228 | NativeMeshHeader[] nativeMeshHeaders = new NativeMeshHeader[20];
229 | for (int h = 0; h < nativeMeshHeaders.Length; h++)
230 | {
231 | NativeMeshHeader header = MemoryMarshal.AsRef(bytes.Slice(offset, 48));
232 | if (header.source_size == 0)
233 | {
234 | Array.Resize(ref nativeMeshHeaders, h);
235 | return nativeMeshHeaders;
236 | }
237 | nativeMeshHeaders[h] = header;
238 | offset += header.source_size + 4;
239 | }
240 | return nativeMeshHeaders;
241 | }
242 |
243 | private static double ZtoElev(float z)
244 | {
245 | double tmp_z = z;
246 | if (tmp_z != 0.0 && tmp_z < 1E-12)
247 | {
248 | tmp_z *= NegativeElevationFactor;
249 | }
250 | return tmp_z * 6371010.0;
251 | }
252 | }
253 |
--------------------------------------------------------------------------------
/src/LibMapCommon/CachedHttpClient.cs:
--------------------------------------------------------------------------------
1 | using LibMapCommon.IO;
2 | using System.Text;
3 |
4 | namespace LibMapCommon;
5 |
6 | public class CachedHttpClient
7 | {
8 | private readonly HttpClient HttpClient = new();
9 | public DirectoryInfo? CacheDir { get; }
10 | public CachedHttpClient(DirectoryInfo? cacheDir)
11 | {
12 | CacheDir = cacheDir;
13 | }
14 |
15 | public Task GetStreamAsync(string url)
16 | => HttpClient.GetStreamAsync(url);
17 |
18 | public async Task GetBytesIfNewer(string url)
19 | {
20 | var fileName = UrlToFileName(url);
21 |
22 | var directory = CacheDir?.Exists is true ? CacheDir.FullName : Path.GetTempPath();
23 | var filePath = new FileInfo(Path.Combine(directory, fileName));
24 |
25 | await using var mutex = await AsyncMutex.AcquireAsync("Global\\" + HashString(filePath.FullName));
26 |
27 | using var request = new HttpRequestMessage(HttpMethod.Get, url);
28 |
29 | if (filePath.Exists)
30 | request.Headers.IfModifiedSince = filePath.LastWriteTimeUtc;
31 |
32 | using var response = await HttpClient.SendAsync(request);
33 |
34 | if (filePath.Exists && response.StatusCode == System.Net.HttpStatusCode.NotModified)
35 | return File.ReadAllBytes(filePath.FullName);
36 | else
37 | {
38 | response.EnsureSuccessStatusCode();
39 | var fileBytes = await response.Content.ReadAsByteArrayAsync();
40 | try
41 | {
42 | File.WriteAllBytes(filePath.FullName, fileBytes);
43 |
44 | if (response.Content.Headers.LastModified.HasValue)
45 | filePath.LastWriteTimeUtc = response.Content.Headers.LastModified.Value.UtcDateTime;
46 | }
47 | catch (Exception ex)
48 | {
49 | Console.Error.WriteLine($"Failed to Cache {filePath.FullName}.");
50 | Console.Error.WriteLine(ex.Message);
51 | }
52 | return fileBytes;
53 | }
54 | }
55 |
56 | ///
57 | /// Download, decrypt and cache a file from Google Earth.
58 | ///
59 | /// The Google Earth asset Url
60 | /// The asset's bytes
61 | public async Task GetByteArrayAsync(string url, Action>? postDownloadAction = null)
62 | {
63 | if (CacheDir?.Exists is true)
64 | {
65 | var fileName = UrlToFileName(url);
66 | var filePath = Path.Combine(CacheDir.FullName, fileName);
67 | await using var mutex = await AsyncMutex.AcquireAsync("Global\\" + fileName);
68 |
69 | if (File.Exists(filePath) && File.ReadAllBytes(filePath) is byte[] b && b.Length > 0)
70 | return b;
71 | else
72 | {
73 | var data = await download();
74 |
75 | try
76 | {
77 | File.WriteAllBytes(filePath, data);
78 | }
79 | catch (Exception ex)
80 | {
81 | Console.Error.WriteLine($"Failed to Cache {url}.");
82 | Console.Error.WriteLine(ex.Message);
83 | }
84 | return data;
85 | }
86 | }
87 | else
88 | return await download();
89 |
90 | async Task download()
91 | {
92 | var data = await HttpClient.GetByteArrayAsync(url);
93 | postDownloadAction?.Invoke(data);
94 | return data;
95 | }
96 | }
97 |
98 | private static string UrlToFileName(string url)
99 | => HashString(url);
100 |
101 | private static string HashString(string s)
102 | => Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(Encoding.UTF8.GetBytes(s)));
103 | }
104 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/Line2.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 | public readonly struct Line2(Vector2 origin, Vector2 direction)
4 | {
5 | public readonly Vector2 Origin = origin;
6 | public readonly Vector2 Direction = direction;
7 |
8 | /// a vector containing the t and u multiples of the two lines at their intersection
9 | public Vector2 Intersect(Line2 other)
10 | {
11 | var A = new Matrix2x2(
12 | Direction.X, -other.Direction.X,
13 | Direction.Y, -other.Direction.Y);
14 |
15 | return
16 | A.Invert(out var A_1)
17 | ? A_1 * (other.Origin - Origin)
18 | : new Vector2(float.NaN, float.NaN);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/Matrix2x2.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 | public readonly struct Matrix2x2(double m11, double m12, double m21, double m22)
4 | {
5 | public readonly double M11 = m11; public readonly double M12 = m12;
6 | public readonly double M21 = m21; public readonly double M22 = m22;
7 |
8 | public static Vector2 operator *(Matrix2x2 m, Vector2 v)
9 | => new Vector2(m.M11 * v.X + m.M12 * v.Y, m.M21 * v.X + m.M22 * v.Y);
10 |
11 | public bool Invert(out Matrix2x2 result)
12 | {
13 | var det = M11 * M22 - M21 * M12;
14 |
15 | if (Math.Abs(det) < double.Epsilon)
16 | {
17 | result = new Matrix2x2(double.NaN, double.NaN, double.NaN, double.NaN);
18 | return false;
19 | }
20 |
21 | var invDet = 1.0 / det;
22 |
23 | result = new Matrix2x2(
24 | M22 * invDet, -M12 * invDet,
25 | -M21 * invDet, M11 * invDet);
26 |
27 | return true;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/PixelPointPoly.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 | public sealed class PixelPointPoly : Polygon, IPolygon
4 | {
5 | public int ZoomLevel { get; }
6 | private Func PointConverter { get; }
7 |
8 | public PixelPointPoly(Func pointConverter, int zoomLevel, params PixelPoint[] points)
9 | : base(points)
10 | {
11 | PointConverter = pointConverter;
12 | ZoomLevel = zoomLevel;
13 | }
14 | private PixelPointPoly(Func pointConverter, int zoomLevel, IEnumerable edges)
15 | : base(edges)
16 | {
17 | PointConverter = pointConverter;
18 | ZoomLevel = zoomLevel;
19 | }
20 |
21 | public override PixelPointPoly ToPixelPolygon(int level)
22 | {
23 | var pixelCoords = new PixelPoint[Edges.Length];
24 |
25 | var pixelScale = Math.Pow(2, level - ZoomLevel);
26 | for (int i = 0; i < Edges.Length; i++)
27 | {
28 | var origin = Edges[i].Origin;
29 | pixelCoords[i] = new PixelPoint(level, origin.X * pixelScale, origin.Y * pixelScale);
30 | }
31 | return new PixelPointPoly(PointConverter, level, pixelCoords);
32 | }
33 |
34 | public PixelPointPoly CreateFromEdges(IEnumerable edges) => new PixelPointPoly(PointConverter, ZoomLevel, edges);
35 | protected override PixelPoint GetFromWgs1984(Wgs1984 point) => PointConverter(point, ZoomLevel);
36 | }
37 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/Polygon.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 |
4 | public interface IPolygon where T : IPolygon
5 | {
6 | public abstract T CreateFromEdges(IEnumerable edges);
7 | }
8 |
9 | public abstract class Polygon where TCoordinate : struct, ICoordinate
10 | {
11 | public double MinX { get; }
12 | public double MinY { get; }
13 | public double MaxX { get; }
14 | public double MaxY { get; }
15 | public Line2[] Edges { get; }
16 |
17 | protected Polygon(params TCoordinate[] coords) : this(CreateEdges(coords)) { }
18 | protected Polygon(IEnumerable edges)
19 | {
20 | MinX = edges.MinBy(v => v.Origin.X).Origin.X;
21 | MinY = edges.MinBy(v => v.Origin.Y).Origin.Y;
22 | MaxX = edges.MaxBy(v => v.Origin.X).Origin.X;
23 | MaxY = edges.MaxBy(v => v.Origin.Y).Origin.Y;
24 | Edges = edges.ToArray();
25 | if (Edges.Length < 3)
26 | throw new ArgumentException("Polygon must contain at least three edges");
27 | }
28 |
29 | protected abstract TCoordinate GetFromWgs1984(Wgs1984 point);
30 |
31 | ///
32 | /// Convert to the global pixel space for the current polygon's coordinate system.
33 | ///
34 | public abstract PixelPointPoly ToPixelPolygon(int level);
35 |
36 | private static Line2[] CreateEdges(T[] coords) where T : ICoordinate
37 | {
38 | var edges = new Line2[coords.Length];
39 | for (int i = 0; i < coords.Length; i++)
40 | {
41 | var origin = coords[i];
42 | var next = coords[(i + 1) % coords.Length];
43 | edges[i] = LineFrom(origin.X, origin.Y, next.X, next.Y);
44 | }
45 | return edges;
46 | }
47 |
48 | protected static Line2 LineFrom(TCoordinate origin, TCoordinate destination)
49 | => LineFrom(origin.X, origin.Y, destination.X, destination.Y);
50 |
51 | protected static Line2 LineFrom(double x1, double y1, double x2, double y2)
52 | => new Line2(new Vector2(x1, y1), new Vector2(x2 - x1, y2 - y1));
53 |
54 |
55 | ///
56 | /// Determine if the point resides inside the polygon using ray casting
57 | ///
58 | public bool ContainsPoint(TCoordinate point) => ContainsPoint(point.X, point.Y);
59 |
60 | ///
61 | /// Determine if the x,y point resides inside the polygon using ray casting
62 | ///
63 | private bool ContainsPoint(double x, double y)
64 | {
65 | if (x < MinX || x > MaxX || y < MinY || y > MaxY) return false;
66 |
67 | var testEdge = new Line2(new Vector2(x, y), Vector2.UnitX);
68 |
69 | int hitCount = 0;
70 | foreach (var edge in Edges)
71 | {
72 | var v = edge.Intersect(testEdge);
73 |
74 | if (v.X > 0 && v.X < 1 && v.Y > 0)
75 | hitCount++;
76 | }
77 |
78 | return (hitCount & 1) == 1;
79 | }
80 |
81 | ///
82 | /// Indicates whether the tile intersects the polyline.
83 | ///
84 | public bool TileOnBroder(ITile tile)
85 | {
86 | var ul = GetFromWgs1984(tile.UpperLeft);
87 | var ur = GetFromWgs1984(tile.UpperRight);
88 | var ll = GetFromWgs1984(tile.LowerLeft);
89 | var lr = GetFromWgs1984(tile.LowerRight);
90 |
91 | Line2[] tileEdges = [LineFrom(ul, ur), LineFrom(ur, lr), LineFrom(lr, ll), LineFrom(ll, ul)];
92 |
93 | return Edges.Any(e => tileEdges.Any(t => LinesIntersect(e, t)));
94 |
95 | static bool LinesIntersect(Line2 l1, Line2 l2)
96 | {
97 | var v = l1.Intersect(l2);
98 | return v.X > 0 && v.X < 1 && v.Y > 0 && v.Y < 1;
99 | }
100 | }
101 |
102 | ///
103 | /// Clip this polygon
104 | ///
105 | /// A collection of polygons which, combined, span the clipped polygon
106 | public TPoly[] Clip(TPoly clippingPolygon) where TPoly : Polygon, IPolygon
107 | => TriangulatePolygon(clippingPolygon).Select(ClipToTriangle).OfType().ToArray();
108 |
109 | ///
110 | /// Convert a polygon to a collection of triangular polygons
111 | ///
112 | public static TPoly[] TriangulatePolygon(TPoly polygon) where TPoly : Polygon, IPolygon
113 | {
114 | //Ear clipping
115 | var triangles = new List(polygon.Edges.Length - 2);
116 |
117 | var poly = polygon.CreateFromEdges(polygon.Edges);
118 | var edges = polygon.Edges.ToList();
119 |
120 | for (int i = 0; edges.Count > 3; i = (i + 1) % edges.Count)
121 | {
122 | var e1 = edges[i];
123 | var e2 = edges[(i + 1) % edges.Count];
124 | var e3 = edges[(i + 2) % edges.Count];
125 |
126 | var v1 = e1.Direction;
127 | var v2 = e2.Direction;
128 |
129 | if (Math.Abs(v1.Dot(v2) / v1.Length / v2.Length) > 0.9999999999)
130 | {
131 | //e1 is colinear with e2 (within 0.00081 degrees)
132 | ClipEdges();
133 | continue;
134 | }
135 |
136 | var centroidX = (e1.Origin.X + e2.Origin.X + e3.Origin.X) / 3;
137 | var centroidY = (e1.Origin.Y + e2.Origin.Y + e3.Origin.Y) / 3;
138 |
139 | if (poly.ContainsPoint(centroidX, centroidY))
140 | {
141 | triangles.Add(polygon.CreateFromEdges([
142 | LineFrom(e1.Origin.X, e1.Origin.Y, e2.Origin.X, e2.Origin.Y),
143 | LineFrom(e2.Origin.X, e2.Origin.Y, e3.Origin.X, e3.Origin.Y),
144 | LineFrom(e3.Origin.X, e3.Origin.Y, e1.Origin.X, e1.Origin.Y)]));
145 |
146 | ClipEdges();
147 | }
148 |
149 | void ClipEdges()
150 | {
151 | edges.RemoveAt(i);
152 | edges[edges.IndexOf(e2)] = LineFrom(e1.Origin.X, e1.Origin.Y, e3.Origin.X, e3.Origin.Y);
153 | poly = polygon.CreateFromEdges(edges);
154 | i--;
155 | }
156 | }
157 |
158 | triangles.Add(poly);
159 | return triangles.ToArray();
160 | }
161 |
162 | ///
163 | /// Sutherland–Hodgman polygon clipping algorithm.
164 | /// Requires the clipping polygon to be convex, so only clip with triangles.
165 | ///
166 | private TPoly? ClipToTriangle(TPoly triangle) where TPoly : Polygon, IPolygon
167 | {
168 | if (triangle.Edges.Length != 3)
169 | throw new ArgumentException("Clipping polygon must be a triangle");
170 |
171 | //Determine triangle direction for easy Inside() checks.
172 | var clockwise = triangle.Edges[0].Direction.Cross(triangle.Edges[1].Direction) < 0;
173 |
174 | List outputList = Edges.Select(e => e.Origin).ToList();
175 |
176 | foreach (var clipEdge in triangle.Edges)
177 | {
178 | List inputList = outputList;
179 | outputList = [];
180 |
181 | for (int i = 0; i < inputList.Count; i++)
182 | {
183 | var prev_point = inputList[i];
184 | var current_point = inputList[(i + 1) % inputList.Count];
185 |
186 | if (Inside(clipEdge, clockwise, current_point))
187 | {
188 | if (!Inside(clipEdge, clockwise, prev_point))
189 | {
190 | outputList.Add(IntersectPoint(clipEdge, prev_point, current_point));
191 | }
192 |
193 | outputList.Add(current_point);
194 | }
195 | else if (Inside(clipEdge, clockwise, prev_point))
196 | {
197 | outputList.Add(IntersectPoint(clipEdge, prev_point, current_point));
198 | }
199 | }
200 | }
201 |
202 | return outputList.Count < 3 ? null : triangle.CreateFromEdges(CreateEdges(outputList.ToArray()));
203 | }
204 |
205 | private static Vector2 IntersectPoint(Line2 clipEdge, Vector2 prev_point, Vector2 current_point)
206 | {
207 | var targetEdge = LineFrom(prev_point.X, prev_point.Y, current_point.X, current_point.Y);
208 | var v = clipEdge.Intersect(targetEdge);
209 | var newX = targetEdge.Origin.X + targetEdge.Direction.X * v.Y;
210 | var newY = targetEdge.Origin.Y + targetEdge.Direction.Y * v.Y;
211 | return new Vector2(newX, newY);
212 | }
213 |
214 | private static bool Inside(Line2 testEdge, bool clockwise, Vector2 point)
215 | => (testEdge.Direction.Cross(point - testEdge.Origin) > 0) ^ clockwise;
216 | }
217 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/Vector2.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 | public readonly struct Vector2(double x, double y) : ICoordinate
4 | {
5 | public readonly double X { get; } = x;
6 | public readonly double Y { get; } = y;
7 |
8 | public static Vector2 UnitX => new Vector2(1, 0);
9 |
10 | public static Vector2 operator -(Vector2 left, Vector2 right)
11 | => new Vector2(left.X - right.X, left.Y - right.Y);
12 | public static Vector2 operator -(Vector2 vector)
13 | => new Vector2(-vector.X, -vector.Y);
14 |
15 | public double Dot(Vector2 other) => X * other.X + Y * other.Y;
16 | public double Cross(Vector2 other) => X * other.Y - other.X * Y;
17 | public double Length => Math.Sqrt(X * X + Y * Y);
18 | }
19 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/WebMercatorPoly.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 | public sealed class WebMercatorPoly : Polygon, IPolygon
4 | {
5 | public WebMercatorPoly(IEnumerable coordinates)
6 | : base(coordinates.ToArray()) { }
7 | private WebMercatorPoly(IEnumerable edges)
8 | : base(edges) { }
9 |
10 | public bool ContainsTile(ITile tile)
11 | => ContainsPoint(tile.Center.ToWebMercator()) || TileOnBroder(tile);
12 |
13 | public WebMercatorPoly CreateFromEdges(IEnumerable edges) => new WebMercatorPoly(edges);
14 |
15 | public override PixelPointPoly ToPixelPolygon(int level)
16 | {
17 | var pixelCoords = new PixelPoint[Edges.Length];
18 | for (int i = 0; i < Edges.Length; i++)
19 | {
20 | var origin = Edges[i].Origin;
21 | var vertex = new WebMercator(origin.X, origin.Y);
22 | pixelCoords[i] = vertex.GetGlobalPixelCoordinate(level);
23 | }
24 | return new PixelPointPoly((p,z) => p.ToWebMercator().GetGlobalPixelCoordinate(z), level, pixelCoords);
25 | }
26 |
27 | protected override WebMercator GetFromWgs1984(Wgs1984 point) => point.ToWebMercator();
28 | }
29 |
--------------------------------------------------------------------------------
/src/LibMapCommon/Geometry/Wgs1984Poly.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.Geometry;
2 |
3 | public sealed class Wgs1984Poly : Polygon, IPolygon
4 | {
5 | public Wgs1984Poly(params Wgs1984[] coords)
6 | : base(coords) { }
7 | private Wgs1984Poly(IEnumerable edges)
8 | : base(edges) { }
9 |
10 | public WebMercatorPoly ToWebMercator()
11 | => new WebMercatorPoly(Edges.Select(e => new Wgs1984(e.Origin.Y, e.Origin.X).ToWebMercator()));
12 |
13 | public Wgs1984Poly CreateFromEdges(IEnumerable edges) => new Wgs1984Poly(edges);
14 |
15 | public Rectangle GetBoundingRectangle()
16 | => new Rectangle(new Wgs1984(MinY, MinX), new Wgs1984(MaxY, MaxX));
17 |
18 | public override PixelPointPoly ToPixelPolygon(int level)
19 | {
20 | var pixelCoords = new PixelPoint[Edges.Length];
21 | for (int i = 0; i < Edges.Length; i++)
22 | {
23 | var origin = Edges[i].Origin;
24 | var vertex = new Wgs1984(origin.Y, origin.X);
25 | pixelCoords[i] = vertex.GetGlobalPixelCoordinate(level);
26 | }
27 | return new PixelPointPoly((p, z) => p.GetGlobalPixelCoordinate(z), level, pixelCoords);
28 | }
29 |
30 | ///
31 | /// Gets the number of tiles required to cover this
32 | ///
33 | /// The zoom level of the tiles
34 | /// The number of tiles required to tile the
35 | public int GetTileCount(int level) where TTile : ITile
36 | => GetTiles(level).Count();
37 |
38 | ///
39 | /// Enumerates the tiles covering this
40 | ///
41 | /// The enumeration starts at the lower-left corner, proceeds left-to-right, then bottom-to-top.
42 | ///
43 | /// The zoom level of the tiles
44 | /// The enumeration
45 | public IEnumerable GetTiles(int level) where TTile : ITile
46 | => GetBoundingRectangle()
47 | .GetTiles(level)
48 | .Where(t => ContainsPoint(t.LowerLeft) ||
49 | ContainsPoint(t.LowerRight) ||
50 | ContainsPoint(t.UpperLeft) ||
51 | ContainsPoint(t.UpperRight) ||
52 | TileOnBroder(t));
53 |
54 | protected override Wgs1984 GetFromWgs1984(Wgs1984 point) => point;
55 | }
--------------------------------------------------------------------------------
/src/LibMapCommon/ICoordinate.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon;
2 |
3 | public interface ICoordinate : ICoordinate where T : ICoordinate
4 | {
5 | static abstract T FromWgs84(Wgs1984 wgs1984);
6 | static abstract int EpsgNumber { get; }
7 | static abstract double Equator { get; }
8 | }
9 |
10 | public interface ICoordinate
11 | {
12 | public double X { get; }
13 | public double Y { get; }
14 | }
15 |
--------------------------------------------------------------------------------
/src/LibMapCommon/IO/AsyncMutex.cs:
--------------------------------------------------------------------------------
1 | namespace LibMapCommon.IO;
2 |
3 | internal static class AsyncMutex
4 | {
5 | private const int MaxValueTasks = 10;
6 | private static readonly CachedValueTaskSource ValueTaskSources = new(MaxValueTasks);
7 |
8 | public static ValueTask AcquireAsync(string mutexName, CancellationToken cancellationToken = default)
9 | {
10 | cancellationToken.ThrowIfCancellationRequested();
11 |
12 | Task? mutexTask = null;
13 |
14 | var taskCompletionSource = ValueTaskSources.GetFreeTaskSource();
15 | //Create the task before starting so when it starts,
16 | //WaitForTask is sure to capture the non-null mutexTask.
17 | mutexTask = new Task(WaitForTask, cancellationToken, TaskCreationOptions.DenyChildAttach);
18 | mutexTask.Start();
19 | return taskCompletionSource.GetValueTask();
20 |
21 | void WaitForTask()
22 | {
23 | try
24 | {
25 | using var mutex = new Mutex(false, mutexName);
26 | try
27 | {
28 | // Wait for either the mutex to be acquired, or cancellation
29 | #if LINUX
30 | while (!mutex.WaitOne(10))
31 | {
32 | if (cancellationToken.IsCancellationRequested)
33 | {
34 | taskCompletionSource.SetCanceled(cancellationToken);
35 | return;
36 | }
37 | }
38 | #else
39 | if (WaitHandle.WaitAny([mutex, cancellationToken.WaitHandle]) != 0)
40 | {
41 | taskCompletionSource.SetCanceled(cancellationToken);
42 | return;
43 | }
44 | #endif
45 | }
46 | catch (AbandonedMutexException)
47 | { /* Abandoned by another process, we acquired it. */ }
48 |
49 | using var releaseEvent = new ManualResetEventSlim();
50 | taskCompletionSource.SetResult(new MutexAwaiter(mutexTask!, releaseEvent));
51 |
52 | // Wait until the release call
53 | releaseEvent.Wait(cancellationToken);
54 | mutex.ReleaseMutex();
55 | }
56 | catch (OperationCanceledException)
57 | {
58 | taskCompletionSource.SetCanceled(cancellationToken);
59 | }
60 | catch (Exception ex)
61 | {
62 | taskCompletionSource.SetException(ex);
63 | }
64 | }
65 | }
66 |
67 | private class MutexAwaiter(Task mutexTask, ManualResetEventSlim releaseEvent) : IAsyncDisposable
68 | {
69 | private readonly Task _mutexTask = mutexTask;
70 | private readonly ManualResetEventSlim _releaseEvent = releaseEvent;
71 |
72 | public async ValueTask DisposeAsync()
73 | {
74 | _releaseEvent.Set();
75 | await _mutexTask;
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/LibMapCommon/IO/CachedValueTaskSource[TResult].cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.Runtime.ExceptionServices;
3 | using System.Threading.Tasks.Sources;
4 |
5 | namespace LibMapCommon.IO;
6 |
7 | internal class CachedValueTaskSource(int capacity)
8 | {
9 | private readonly ValueTaskSource[] ValueTaskSources
10 | = Enumerable.Range(0, capacity)
11 | .Select(_ => new ValueTaskSource())
12 | .ToArray();
13 |
14 | public ITaskCompletionSource GetFreeTaskSource()
15 | => FirstFreeSlot() ?? new TaskCompletionSourceEx();
16 |
17 | private ITaskCompletionSource? FirstFreeSlot()
18 | {
19 | for (int i = 0; i < ValueTaskSources.Length; i++)
20 | {
21 | if (Interlocked.CompareExchange(ref ValueTaskSources[i].Index, i, -1) == -1)
22 | return ValueTaskSources[i];
23 | }
24 | return null;
25 | }
26 |
27 | private class TaskCompletionSourceEx : TaskCompletionSource, ITaskCompletionSource
28 | {
29 | public ValueTask GetValueTask() => new(Task);
30 | }
31 |
32 | /// Provides the core logic for implementing a .
33 | /// Cribbed from
34 | /// Specifies the type of results of the operation represented by this instance.
35 | private class ValueTaskSource : ITaskCompletionSource, IValueTaskSource
36 | {
37 | public int Index = -1;
38 | ///
39 | /// The callback to invoke when the operation completes if was called before the operation completed,
40 | /// or if the operation completed before a callback was supplied,
41 | /// or null if a callback hasn't yet been provided and the operation hasn't yet completed.
42 | ///
43 | private Action