├── .gitattributes ├── .github └── workflows │ ├── build-linux.yml │ ├── build-windows.yml │ ├── build.yml │ ├── release.yml │ └── validate.yml ├── .gitignore ├── GEHistoricalImagery.sln ├── LICENSE.txt ├── README.md ├── docs ├── README.md ├── assets │ ├── Cherry Creek 1-Small.jpg │ ├── Cherry Creek 2-Small.jpg │ ├── Cherry Creek 3-Small.jpg │ └── Cherry Creek 4-Small.jpg ├── availability.md ├── download.md ├── dump.md └── info.md ├── gehinix.sh ├── src ├── GEHistoricalImagery │ ├── Cli │ │ ├── AoiVerb.cs │ │ ├── Availability.cs │ │ ├── Download.cs │ │ ├── Dump.cs │ │ ├── Info.cs │ │ ├── OptionChooser.cs │ │ └── OptionsBase.cs │ ├── CoordinateSystem.cs │ ├── EarthImage.cs │ ├── GEHistoricalImagery.csproj │ ├── OSGeo.GDAL │ │ ├── GDALExtensions.cs │ │ └── GeoTransform.cs │ ├── ParallelProcessor.cs │ └── Program.cs ├── LibEsri │ ├── Capabilities.cs │ ├── DatedEsriTile.cs │ ├── EsriExtensions.cs │ ├── EsriTile.cs │ ├── Geometry │ │ └── DatedRegion.cs │ ├── Layer.cs │ ├── LibEsri.csproj │ └── WayBack.cs ├── LibGoogleEarth │ ├── DatedTile.cs │ ├── DbRoot.cs │ ├── DefaultDbRoot.cs │ ├── IEarthAsset.cs │ ├── Keyhole │ │ ├── IQuadtreeChannel.cs │ │ ├── IQuadtreeLayer.cs │ │ ├── IQuadtreeNode.cs │ │ ├── IQuadtreePacket.cs │ │ ├── ISparseQuadtreeNode.cs │ │ ├── KhQuadTreeBTG.cs │ │ ├── KhQuadTreePacket16.cs │ │ ├── KhQuadTreePacketHeader.cs │ │ ├── KhQuadTreeQuantum16.cs │ │ ├── KhQuadtreeChannel.cs │ │ ├── KhQuadtreeLayer.cs │ │ ├── KhQuadtreeNode.cs │ │ ├── KhSparseQuadtreeNode.cs │ │ ├── QuadtreeChannel.cs │ │ ├── QuadtreeImageryDatedTile.cs │ │ ├── QuadtreeLayer.cs │ │ ├── QuadtreeNode.cs │ │ └── QuadtreePacket.cs │ ├── KeyholeTile.cs │ ├── LibGoogleEarth.csproj │ ├── NamedDbRoot.cs │ ├── ProtoBuf │ │ ├── DbrootV2.cs │ │ └── Quadtreeset.cs │ ├── TerrainTile.cs │ ├── TileNode.cs │ ├── Util.cs │ └── WORKINGPROGRESS │ │ ├── GridMesh.cs │ │ └── TerrainMesh.cs └── LibMapCommon │ ├── CachedHttpClient.cs │ ├── Geometry │ ├── Line2.cs │ ├── Matrix2x2.cs │ ├── PixelPointPoly.cs │ ├── Polygon.cs │ ├── Vector2.cs │ ├── WebMercatorPoly.cs │ └── Wgs1984Poly.cs │ ├── ICoordinate.cs │ ├── IO │ ├── AsyncMutex.cs │ ├── CachedValueTaskSource[TResult].cs │ └── ITaskCompletionSource[TResult].cs │ ├── ITile.cs │ ├── LibMapCommon.csproj │ ├── PixelPoint.cs │ ├── Rectangle.cs │ ├── TypeConverters │ └── Wgs1984TypeConverter.cs │ ├── Util.cs │ ├── WebMercator.cs │ └── Wgs1984.cs └── test ├── GEHistoricalImageryTest ├── GEHistoricalImageryTest.csproj └── RectangleTests.cs └── LibGoogleEarthTest ├── CoordinateTests.cs ├── KeyholeTileTests.cs ├── LibGoogleEarthTest.csproj ├── QtPathTest.cs ├── RootIndexDictionary.json └── SubIndexDictionary.json /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/build-linux.yml: -------------------------------------------------------------------------------- 1 | # build-linux.yml 2 | # Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation. 3 | --- 4 | name: build 5 | 6 | on: 7 | workflow_call: 8 | inputs: 9 | version_override: 10 | type: string 11 | description: "Version number override" 12 | required: false 13 | runs_on: 14 | type: string 15 | description: "The GitHub hosted runner to use" 16 | required: true 17 | architecture: 18 | type: string 19 | description: "CPU architecture targeted by the build." 20 | required: true 21 | 22 | env: 23 | DOTNET_CONFIGURATION: "Release" 24 | DOTNET_VERSION: "9.0.x" 25 | 26 | jobs: 27 | build: 28 | name: "linux-${{ inputs.architecture }}" 29 | runs-on: ${{ inputs.runs_on }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Setup .NET 33 | uses: actions/setup-dotnet@v4 34 | with: 35 | dotnet-version: ${{ env.DOTNET_VERSION }} 36 | 37 | - name: Get version 38 | id: get_version 39 | run: | 40 | inputVersion="${{ inputs.version_override }}" 41 | if [[ "${#inputVersion}" -gt 0 ]] 42 | then 43 | version="${inputVersion}" 44 | else 45 | version="$(grep -Eio -m 1 '.*' ./src/GEHistoricalImagery/GEHistoricalImagery.csproj | sed -r 's/<\/?Version>//g')" 46 | fi 47 | echo "version=${version}" >> "${GITHUB_OUTPUT}" 48 | 49 | - name: Publish 50 | id: publish 51 | working-directory: ./src 52 | run: | 53 | preprocessor="LINUX" 54 | RUNTIME_ID="linux-${{ inputs.architecture }}" 55 | 56 | OUTPUT="bin/Publish/$RUNTIME_ID/gdal" 57 | 58 | echo "Runtime Identifier: $RUNTIME_ID" 59 | echo "Output Directory: $OUTPUT" 60 | 61 | dotnet publish \ 62 | GEHistoricalImagery/GEHistoricalImagery.csproj \ 63 | --runtime $RUNTIME_ID \ 64 | --configuration ${{ env.DOTNET_CONFIGURATION }} \ 65 | --output $OUTPUT \ 66 | -p:DefineConstants=$preprocessor \ 67 | -p:PublishProtocol=FileSystem \ 68 | -p:SelfContained=true \ 69 | -p:PublishTrimmed=true \ 70 | -p:PublishSingleFile=true 71 | 72 | - name: Build bundle 73 | id: bundle 74 | working-directory: ./src/bin/Publish/linux-${{ inputs.architecture }} 75 | run: | 76 | 77 | BUNDLE_DIR=$(pwd) 78 | echo "Bundle dir: ${BUNDLE_DIR}" 79 | scriptfile=GEHistoricalImagery 80 | 81 | echo -e '#!/usr/bin/env bash' > $scriptfile 82 | echo -e 'SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )' >> $scriptfile 83 | echo -e 'export GEHistoricalImagery_Cache="$SCRIPT_DIR/cache"' >> $scriptfile 84 | echo -e 'export GDAL_DATA="$SCRIPT_DIR/gdal"' >> $scriptfile 85 | echo -e 'export GDAL_DRIVER_PATH="$SCRIPT_DIR/gdal"' >> $scriptfile 86 | echo -e 'export PROJ_LIB="$SCRIPT_DIR/gdal"' >> $scriptfile 87 | echo -e '"$SCRIPT_DIR/gdal/GEHistoricalImagery" "$@"' >> $scriptfile 88 | chmod +x $scriptfile 89 | 90 | artifact="GEHistoricalImagery.${{ steps.get_version.outputs.version }}-linux-${{ inputs.architecture }}.tar.gz" 91 | 92 | tar -cvz -f "./../$artifact" * 93 | echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}" 94 | 95 | - name: Publish bundle 96 | uses: actions/upload-artifact@v4 97 | with: 98 | name: ${{ steps.bundle.outputs.artifact }} 99 | path: ./src/bin/Publish/${{ steps.bundle.outputs.artifact }} 100 | if-no-files-found: error 101 | retention-days: 7 -------------------------------------------------------------------------------- /.github/workflows/build-windows.yml: -------------------------------------------------------------------------------- 1 | # build-windows.yml 2 | --- 3 | name: build 4 | 5 | on: 6 | workflow_call: 7 | inputs: 8 | version_override: 9 | type: string 10 | description: "Version number override" 11 | required: false 12 | run_unit_tests: 13 | type: boolean 14 | description: "Skip running unit tests" 15 | required: false 16 | default: true 17 | architecture: 18 | type: string 19 | description: "CPU architecture targeted by the build." 20 | required: true 21 | 22 | env: 23 | DOTNET_CONFIGURATION: "Release" 24 | DOTNET_VERSION: "9.0.x" 25 | 26 | jobs: 27 | build: 28 | name: "win-${{ inputs.architecture }}" 29 | runs-on: windows-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Setup .NET 33 | uses: actions/setup-dotnet@v4 34 | with: 35 | dotnet-version: ${{ env.DOTNET_VERSION }} 36 | 37 | - name: Get version 38 | id: get_version 39 | run: | 40 | if ("${{ inputs.version_override }}".length -gt 0) { 41 | $version = "${{ inputs.version_override }}" 42 | } else { 43 | $version = (Select-Xml -Path "./src/GEHistoricalImagery/GEHistoricalImagery.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim() 44 | } 45 | "version=$version" >> $env:GITHUB_OUTPUT 46 | 47 | - name: Publish 48 | working-directory: ./src 49 | run: | 50 | $RUNTIME_ID="win-${{ inputs.architecture }}" 51 | 52 | $OUTPUT="bin/Publish/win-${{ inputs.architecture }}" 53 | 54 | echo "Runtime Identifier: $RUNTIME_ID" 55 | echo "Output Directory: $OUTPUT" 56 | 57 | dotnet publish ` 58 | GEHistoricalImagery/GEHistoricalImagery.csproj ` 59 | --runtime $RUNTIME_ID ` 60 | --configuration ${{ env.DOTNET_CONFIGURATION }} ` 61 | --output $OUTPUT ` 62 | -p:PublishProtocol=FileSystem ` 63 | -p:SelfContained=true ` 64 | -p:PublishAot=true 65 | 66 | 67 | Copy-Item "GEHistoricalImagery\bin\Release\gdal" "$OUTPUT\" -Force -Recurse 68 | 69 | if ("${{ inputs.architecture }}" -eq "x64") { Remove-Item -Path "$OUTPUT\gdal\x86" -Force -Recurse } 70 | if ("${{ inputs.architecture }}" -eq "x86") { Remove-Item -Path "$OUTPUT\gdal\x64" -Force -Recurse } 71 | 72 | - name: Zip artifact 73 | id: zip 74 | working-directory: ./src/bin/Publish 75 | run: | 76 | $bin_dir = "win-${{ inputs.architecture }}\" 77 | $artifact="GEHistoricalImagery.${{ steps.get_version.outputs.version }}-win-${{ inputs.architecture }}.zip" 78 | "artifact=$artifact" >> $env:GITHUB_OUTPUT 79 | Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact" 80 | 81 | - name: Publish artifact 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: GEHistoricalImagery.${{ steps.get_version.outputs.version }}-win-${{ inputs.architecture }}.zip 85 | path: ./src/bin/Publish/${{ steps.zip.outputs.artifact }} 86 | if-no-files-found: error 87 | retention-days: 7 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # build.yml 2 | --- 3 | name: build 4 | 5 | on: 6 | workflow_call: 7 | inputs: 8 | version_override: 9 | type: string 10 | description: "Version number override" 11 | required: false 12 | 13 | jobs: 14 | windows: 15 | strategy: 16 | matrix: 17 | architecture: [x64, x86] 18 | uses: ./.github/workflows/build-windows.yml 19 | with: 20 | version_override: ${{ inputs.version_override }} 21 | architecture: ${{ matrix.architecture }} 22 | 23 | linux: 24 | strategy: 25 | matrix: 26 | architecture: [x64, arm64] 27 | uses: ./.github/workflows/build-linux.yml 28 | with: 29 | version_override: ${{ inputs.version_override }} 30 | runs_on: ubuntu-latest 31 | architecture: ${{ matrix.architecture }} 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # release.yml 2 | # Builds and creates the release on any tags starting with a `v` 3 | --- 4 | name: release 5 | on: 6 | push: 7 | tags: 8 | - "v*" 9 | jobs: 10 | prerelease: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | version: ${{ steps.get_version.outputs.version }} 14 | steps: 15 | - name: Get tag version 16 | id: get_version 17 | run: | 18 | export TAG="${{ github.ref_name }}" 19 | echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}" 20 | 21 | build: 22 | needs: [prerelease] 23 | uses: ./.github/workflows/build.yml 24 | with: 25 | version_override: ${{ needs.prerelease.outputs.version }} 26 | 27 | release: 28 | needs: [prerelease, build] 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Download artifacts 32 | uses: actions/download-artifact@v4 33 | with: 34 | pattern: "*GEHistoricalImagery.*" 35 | path: artifacts 36 | 37 | - name: Release 38 | id: release 39 | uses: softprops/action-gh-release@v2 40 | with: 41 | name: GEHistoricalImagery ${{ needs.prerelease.outputs.version }} 42 | body: 43 | token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} 44 | draft: true 45 | prerelease: false 46 | files: | 47 | artifacts/*/* 48 | 49 | 50 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | # validate.yml 2 | --- 3 | name: validate 4 | 5 | on: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | uses: ./.github/workflows/build.yml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd 364 | launchSettings.json 365 | -------------------------------------------------------------------------------- /GEHistoricalImagery.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.7.34031.279 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GEHistoricalImagery", "src\GEHistoricalImagery\GEHistoricalImagery.csproj", "{FAAE9C2B-4143-47B8-A53D-400772745352}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibGoogleEarthTest", "test\LibGoogleEarthTest\LibGoogleEarthTest.csproj", "{DD6EFB14-2BC5-4E33-BA2D-22924D2E416F}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibGoogleEarth", "src\LibGoogleEarth\LibGoogleEarth.csproj", "{DF26F20C-D25E-4DA9-8875-E531BC9BF6F6}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GEHistoricalImageryTest", "test\GEHistoricalImageryTest\GEHistoricalImageryTest.csproj", "{F8CC5A48-9F74-413F-B0A9-F81DE65AC02E}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C5CECB83-A720-4CBD-B1C5-548C65B0E5E4}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMapCommon", "src\LibMapCommon\LibMapCommon.csproj", "{905B3292-BDCD-4951-8CD4-71954E4A15AA}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibEsri", "src\LibEsri\LibEsri.csproj", "{284B7727-01F1-4761-B711-509A1C75EAB1}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 26 | {FAAE9C2B-4143-47B8-A53D-400772745352}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {FAAE9C2B-4143-47B8-A53D-400772745352}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {FAAE9C2B-4143-47B8-A53D-400772745352}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {FAAE9C2B-4143-47B8-A53D-400772745352}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {DD6EFB14-2BC5-4E33-BA2D-22924D2E416F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {DD6EFB14-2BC5-4E33-BA2D-22924D2E416F}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {DD6EFB14-2BC5-4E33-BA2D-22924D2E416F}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {DD6EFB14-2BC5-4E33-BA2D-22924D2E416F}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {DF26F20C-D25E-4DA9-8875-E531BC9BF6F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {DF26F20C-D25E-4DA9-8875-E531BC9BF6F6}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {DF26F20C-D25E-4DA9-8875-E531BC9BF6F6}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {DF26F20C-D25E-4DA9-8875-E531BC9BF6F6}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {F8CC5A48-9F74-413F-B0A9-F81DE65AC02E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {F8CC5A48-9F74-413F-B0A9-F81DE65AC02E}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {F8CC5A48-9F74-413F-B0A9-F81DE65AC02E}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {F8CC5A48-9F74-413F-B0A9-F81DE65AC02E}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {905B3292-BDCD-4951-8CD4-71954E4A15AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {905B3292-BDCD-4951-8CD4-71954E4A15AA}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {905B3292-BDCD-4951-8CD4-71954E4A15AA}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {905B3292-BDCD-4951-8CD4-71954E4A15AA}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {284B7727-01F1-4761-B711-509A1C75EAB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {284B7727-01F1-4761-B711-509A1C75EAB1}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {284B7727-01F1-4761-B711-509A1C75EAB1}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {284B7727-01F1-4761-B711-509A1C75EAB1}.Release|Any CPU.Build.0 = Release|Any CPU 50 | EndGlobalSection 51 | GlobalSection(SolutionProperties) = preSolution 52 | HideSolutionNode = FALSE 53 | EndGlobalSection 54 | GlobalSection(NestedProjects) = preSolution 55 | {DD6EFB14-2BC5-4E33-BA2D-22924D2E416F} = {C5CECB83-A720-4CBD-B1C5-548C65B0E5E4} 56 | {F8CC5A48-9F74-413F-B0A9-F81DE65AC02E} = {C5CECB83-A720-4CBD-B1C5-548C65B0E5E4} 57 | EndGlobalSection 58 | GlobalSection(ExtensibilityGlobals) = postSolution 59 | SolutionGuid = {7C3EC584-BB32-40B8-93F1-D431BD866A28} 60 | EndGlobalSection 61 | EndGlobal 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./docs/README.md -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # GEHistoricalImagery 2 | GEHistoricalImagery is a utility for downloading historical aerial imagery from Google Earth... 3 | **and now also from Esri's World Atlas Wayback** 4 | 5 | **Features** 6 | - Find historical imagery availability at any location and zoom level 7 | - Always uses the most recent provider data 8 | - Automatically substitutes unavailable tiles with temporally closest available tile 9 | - Outputs a georeferenced GeoTiff or dumps image tiles to a folder 10 | - Supports warping to new coordinate systems 11 | - Fast! Parallel downloading and local caching 12 | 13 | **Commands** 14 | |Command|Description| 15 | |-|-| 16 | |[info](https://github.com/Mbucari/GEHistoricalImagery/blob/master/docs/info.md)|Get imagery info at a specified location.| 17 | |[availability](https://github.com/Mbucari/GEHistoricalImagery/blob/master/docs/availability.md)|Get imagery date availability in a specified region.| 18 | |[download](https://github.com/Mbucari/GEHistoricalImagery/blob/master/docs/download.md)|Download historical imagery.| 19 | |[dump](https://github.com/Mbucari/GEHistoricalImagery/blob/master/docs/dump.md)|Dump historical image tiles into a folder.| 20 | 21 | ************************ 22 | ## Build and Run on Linux (x64 and arm64) 23 | 24 | Ideally you should use the Release binary packaged, but I've provided `gehinix.sh` to download, build and run GEHistoricalImagery. 25 | 26 | The script will: 27 | - download and install the dotnet sdk (if necessary) 28 | - Clone and build the master branch of this repo (if necessary) 29 | - And finally run GEHistoricalImagery with arguments 30 | 31 | ```console 32 | wget https://raw.githubusercontent.com/Mbucari/GEHistoricalImagery/refs/heads/master/gehinix.sh 33 | chmod +x gehinix.sh 34 | ./gehinix.sh 35 | ``` 36 | 37 | ************************ 38 |

Updated 2025/02/26

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 | ![Cherry Creek 1-Small.jpg](assets/Cherry%20Creek%201-Small.jpg) 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 | ![Cherry Creek 2-Small.jpg](assets/Cherry%20Creek%202-Small.jpg) 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 | ![Cherry Creek 3-Small.jpg](assets/Cherry%20Creek%203-Small.jpg) 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 | ![Cherry Creek 4-Small.jpg](assets/Cherry%20Creek%204-Small.jpg) 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? _continuation; 44 | /// State to pass to . 45 | private object? _continuationState; 46 | /// The exception with which the operation failed, or null if it hasn't yet completed or completed successfully. 47 | private ExceptionDispatchInfo? _error; 48 | /// The result with which the operation succeeded, or the default value if it hasn't yet completed or failed. 49 | private TResult? _result; 50 | /// Whether the current operation has completed. 51 | private bool _completed; 52 | 53 | public ValueTask GetValueTask() 54 | => new ValueTask(this, (short)Index); 55 | 56 | public void Reset() 57 | { 58 | Index = -1; 59 | _continuation = null; 60 | _continuationState = null; 61 | _error = null; 62 | _result = default; 63 | _completed = false; 64 | } 65 | 66 | /// Completes with a successful result. 67 | /// The result. 68 | public void SetResult(TResult result) 69 | { 70 | _result = result; 71 | SignalCompletion(); 72 | } 73 | 74 | /// Completes with an error. 75 | /// The exception. 76 | public void SetException(Exception error) 77 | { 78 | _error = ExceptionDispatchInfo.Capture(error); 79 | SignalCompletion(); 80 | } 81 | 82 | public void SetCanceled(CancellationToken cancellationToken) 83 | => SetException(new OperationCanceledException(cancellationToken)); 84 | 85 | /// Gets the result of the operation. 86 | [StackTraceHidden] 87 | TResult IValueTaskSource.GetResult(short token) 88 | { 89 | ValidateToken(token); 90 | if (!_completed || _error is not null) 91 | ThrowForFailedGetResult(); 92 | 93 | var result = _result!; 94 | Reset(); 95 | return result; 96 | } 97 | 98 | /// Gets the status of the operation. 99 | ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) 100 | { 101 | ValidateToken(token); 102 | return Volatile.Read(ref _continuation) is null || !_completed ? ValueTaskSourceStatus.Pending : 103 | _error is null ? ValueTaskSourceStatus.Succeeded : 104 | _error.SourceException is OperationCanceledException ? ValueTaskSourceStatus.Canceled : 105 | ValueTaskSourceStatus.Faulted; 106 | } 107 | 108 | /// Schedules the continuation action for this operation. 109 | /// The continuation to invoke when the operation has completed. 110 | /// The state object to pass to when it's invoked. 111 | /// The flags describing the behavior of the continuation. 112 | void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) 113 | { 114 | ArgumentNullException.ThrowIfNull(continuation, nameof(continuation)); 115 | ValidateToken(token); 116 | 117 | // We need to set the continuation state before we swap in the delegate, so that 118 | // if there's a race between this and SetResult/Exception and SetResult/Exception 119 | // sees the _continuation as non-null, it'll be able to invoke it with the state 120 | // stored here. However, this also means that if this is used incorrectly (e.g. 121 | // awaited twice concurrently), _continuationState might get erroneously overwritten. 122 | // To minimize the chances of that, we check preemptively whether _continuation 123 | // is already set to something other than the completion sentinel. 124 | object? storedContinuation = _continuation; 125 | if (storedContinuation is null) 126 | { 127 | _continuationState = state; 128 | storedContinuation = Interlocked.CompareExchange(ref _continuation, continuation, null); 129 | if (storedContinuation is null) 130 | { 131 | // Operation hadn't already completed, so we're done. The continuation will be 132 | // invoked when SetResult/Exception is called at some later point. 133 | return; 134 | } 135 | } 136 | 137 | // Operation already completed, so we need to queue the supplied callback. 138 | // At this point the storedContinuation should be the sentinel; if it's not, the instance was misused. 139 | Debug.Assert(storedContinuation is not null, $"{nameof(storedContinuation)} is null"); 140 | ThreadPool.QueueUserWorkItem(continuation, state, preferLocal: true); 141 | } 142 | 143 | private void ValidateToken(short token) 144 | { 145 | ArgumentOutOfRangeException.ThrowIfNegative(token, nameof(token)); 146 | if (Index != token) 147 | throw new InvalidOperationException(); 148 | } 149 | 150 | /// Signals that the operation has completed. Invoked after the result or error has been set. 151 | private void SignalCompletion() 152 | { 153 | if (_completed) 154 | throw new InvalidOperationException(); 155 | 156 | _completed = true; 157 | 158 | Action? continuation = 159 | Volatile.Read(ref _continuation) ?? 160 | Interlocked.CompareExchange(ref _continuation, CompletionSentinel, null); 161 | 162 | if (continuation is not null) 163 | { 164 | Debug.Assert(continuation is not null, $"{nameof(continuation)} is null"); 165 | continuation(_continuationState); 166 | } 167 | } 168 | 169 | private static void CompletionSentinel(object? _) // named method to aid debugging 170 | { 171 | Debug.Fail("The sentinel delegate should never be invoked."); 172 | throw new InvalidOperationException(); 173 | } 174 | 175 | [StackTraceHidden] 176 | private void ThrowForFailedGetResult() 177 | { 178 | _error?.Throw(); 179 | throw new InvalidOperationException(); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/LibMapCommon/IO/ITaskCompletionSource[TResult].cs: -------------------------------------------------------------------------------- 1 | namespace LibMapCommon.IO; 2 | 3 | internal interface ITaskCompletionSource 4 | { 5 | void SetResult(TResult result); 6 | void SetException(Exception exception); 7 | void SetCanceled(CancellationToken cancellationToken); 8 | ValueTask GetValueTask(); 9 | } 10 | -------------------------------------------------------------------------------- /src/LibMapCommon/ITile.cs: -------------------------------------------------------------------------------- 1 | namespace LibMapCommon; 2 | 3 | public interface ITile : ITile 4 | { 5 | static abstract T GetTile(Wgs1984 coordinate, int level); 6 | static abstract T GetMinimumCorner(Wgs1984 c1, Wgs1984 c2, int level); 7 | static abstract T Create(int row, int col, int level); 8 | } 9 | 10 | public interface ITile 11 | { 12 | int Row { get; } 13 | /// The number of columns from the left-most (west-most) edge of the map. 14 | int Column { get; } 15 | /// The 's zoom level. 16 | int Level { get; } 17 | 18 | /// The lower-left (southwest) of this 19 | Wgs1984 LowerLeft { get; } 20 | /// The lower-right (southeast) of this 21 | Wgs1984 LowerRight { get; } 22 | /// The upper-left (northwest) of this 23 | Wgs1984 UpperLeft { get; } 24 | /// The upper-right (northeast) of this 25 | Wgs1984 UpperRight { get; } 26 | /// of the center of this 27 | Wgs1984 Center { get; } 28 | } 29 | -------------------------------------------------------------------------------- /src/LibMapCommon/LibMapCommon.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | embedded 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/LibMapCommon/PixelPoint.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace LibMapCommon; 4 | 5 | public readonly struct PixelPoint : IEquatable, ICoordinate 6 | { 7 | private readonly double _X, _Y; 8 | public double X => _X; 9 | 10 | public double Y => _Y; 11 | 12 | /// 13 | /// Initialize a new instance. 14 | /// 15 | /// The pixel's global X coordinate 16 | /// The pixel's global Y coordinate 17 | public PixelPoint(int level, double x, double y) 18 | { 19 | ArgumentOutOfRangeException.ThrowIfNegativeOrZero(level, nameof(level)); 20 | var equator = 256 << level; 21 | ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(double.Abs(x), equator, nameof(x)); 22 | ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(double.Abs(y), equator, nameof(y)); 23 | 24 | _X = x; 25 | _Y = y; 26 | } 27 | 28 | public override int GetHashCode() 29 | => HashCode.Combine(_X, _Y); 30 | public bool Equals(PixelPoint other) 31 | => other.X == X && other.Y == Y; 32 | public override bool Equals([NotNullWhen(true)] object? obj) 33 | => obj is PixelPoint other && Equals(other); 34 | } 35 | -------------------------------------------------------------------------------- /src/LibMapCommon/Rectangle.cs: -------------------------------------------------------------------------------- 1 | namespace LibMapCommon; 2 | 3 | /// 4 | /// A region of space on earth defined by the lower-left and upper-right geographic coordinates. 5 | /// 6 | public readonly struct Rectangle 7 | { 8 | /// The lower-left (southwest) corner of the 9 | public readonly Wgs1984 LowerLeft; 10 | /// The upper-right (northeast) corner of the 11 | public readonly Wgs1984 UpperRight; 12 | 13 | /// Gets the upper-left (northwest) corner of the 14 | public TCoordinate GetUpperLeft() where TCoordinate : ICoordinate 15 | => TCoordinate.FromWgs84(new Wgs1984(UpperRight.Latitude, LowerLeft.Longitude)); 16 | 17 | /// Gets the lower-right (southeast) corner of the 18 | public TCoordinate GetLowerRight() where TCoordinate : ICoordinate 19 | => TCoordinate.FromWgs84(new Wgs1984(LowerLeft.Latitude, UpperRight.Longitude)); 20 | 21 | /// 22 | /// Initializes a new instance of a area on earth's surface by the lower-left and upper-right coordinates. 23 | /// 24 | /// The lower-left corner of the 25 | /// The lower-left corner of the 26 | /// 27 | public Rectangle(Wgs1984 lowerLeft, Wgs1984 upperRight) 28 | { 29 | if (!lowerLeft.IsValidGeographicCoordinate) 30 | throw new ArgumentException($"Invalid geographic coordinate {lowerLeft}", nameof(lowerLeft)); 31 | if (!upperRight.IsValidGeographicCoordinate) 32 | throw new ArgumentException($"Invalid geographic coordinate {upperRight}", nameof(upperRight)); 33 | if (lowerLeft.Latitude >= upperRight.Latitude) 34 | throw new ArgumentException($"{nameof(lowerLeft)} is not south of {nameof(upperRight)}"); 35 | if (lowerLeft.Longitude == upperRight.Longitude) 36 | throw new ArgumentException($"{nameof(lowerLeft)} and {nameof(upperRight)} have the same longitude"); 37 | 38 | LowerLeft = lowerLeft; 39 | UpperRight = upperRight; 40 | } 41 | /// 42 | /// Gets the number of rows and columns comprising this at a specific zoom level 43 | /// 44 | /// 45 | /// The number of rows from the lower (south) tile to the upper tile (inclusive) 46 | /// The number of columns from the left tile to the upper tile (inclusive) 47 | public void GetNumRowsAndColumns(int level, out int nRows, out int nColumns) where TTile : ITile 48 | { 49 | var ll = LowerLeft.GetTile(level); 50 | var ur = UpperRight.GetTile(level); 51 | 52 | nColumns = Util.Mod(ur.Column - ll.Column, 1 << level) + 1; 53 | nRows = int.Abs(ur.Row - ll.Row) + 1; 54 | } 55 | 56 | /// 57 | /// Gets the number of tiles required to cover this 58 | /// 59 | /// The zoom level of the tiles 60 | /// The number of tiles required to tile the 61 | public int GetTileCount(int level) where TTile : ITile 62 | { 63 | GetNumRowsAndColumns(level, out var nRows, out var nColumns); 64 | return nRows * nColumns; 65 | } 66 | 67 | /// 68 | /// Enumerates the tiles covering this 69 | /// 70 | /// The enumeration starts at the lower-left corner, procedes left-to-right, then bottom-to-top. 71 | /// 72 | /// The zoom level of the tiles 73 | /// The enumeration 74 | public IEnumerable GetTiles(int level) where TTile : ITile 75 | { 76 | var minCorner = TTile.GetMinimumCorner(LowerLeft, UpperRight, level); 77 | GetNumRowsAndColumns(level, out var nRows, out var nColumns); 78 | 79 | int numTiles = 1 << level; 80 | 81 | for (int r = 0; r < nRows; r++) 82 | { 83 | for (int c = 0; c < nColumns; c++) 84 | { 85 | var row = (minCorner.Row + r) % numTiles; 86 | var col = (minCorner.Column + c) % numTiles; 87 | yield return TTile.Create(row, col, level); 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/LibMapCommon/TypeConverters/Wgs1984TypeConverter.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Globalization; 3 | 4 | namespace LibMapCommon; 5 | 6 | public class Wgs1984TypeConverter : TypeConverter 7 | { 8 | public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) 9 | { 10 | if (value is not string text) return null; 11 | 12 | var split = text.Split(','); 13 | 14 | if (split.Length != 2) return null; 15 | 16 | if (!double.TryParse(split[0].Trim(), CultureInfo.InvariantCulture, out var lat) || 17 | !double.TryParse(split[1].Trim(), CultureInfo.InvariantCulture, out var lng)) 18 | return null; 19 | 20 | if (lat < -90 || lat > 90 || lng < -180 || lng > 180) 21 | return null; 22 | 23 | return new Wgs1984(lat, lng); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/LibMapCommon/Util.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace LibMapCommon; 4 | 5 | public static class Util 6 | { 7 | public static int Mod(int value, int modulus) 8 | { 9 | var result = value % modulus; 10 | return result >= 0 ? result : result + modulus; 11 | } 12 | 13 | private static PixelPoint CoordinateToPixel(double x, double y, int level, double equator) 14 | { 15 | //https://github.com/Leaflet/Leaflet/discussions/8100 16 | const int TILE_SZ = 256; 17 | const int HALF_TILE = TILE_SZ / 2; 18 | var size = 1 << level; 19 | 20 | var tileX = (HALF_TILE + x * TILE_SZ / equator) * size; 21 | var tileY = (HALF_TILE - y * TILE_SZ / equator) * size; 22 | return new PixelPoint(level, tileX, tileY); 23 | } 24 | 25 | public static int ToRoundedInt(this double value) => (int)Math.Round(value, 0); 26 | 27 | public static PixelPoint GetGlobalPixelCoordinate(this T coordinate, int level) 28 | where T : ICoordinate 29 | => CoordinateToPixel(coordinate.X, coordinate.Y, level, T.Equator); 30 | 31 | /// 32 | /// Get the global pixel coordinates of the corner of this tile. 33 | /// 34 | /// X and Y coordinates of the pixel in global pixel space 35 | public static PixelPoint GetTopLeftPixel(this ITile tile) 36 | where T : ICoordinate 37 | { 38 | var topLeft = T.FromWgs84(tile.UpperLeft); 39 | 40 | var pixel = topLeft.GetGlobalPixelCoordinate(tile.Level); 41 | //A tile corner coordinate should always be on an integer pixel. 42 | //Due to floating point errors, use rounding instead of floor/casting to int. 43 | return new PixelPoint(tile.Level, pixel.X.ToRoundedInt(), pixel.Y.ToRoundedInt()); 44 | } 45 | 46 | [StackTraceHidden] 47 | public static int ValidateLevel(int level, int maxLevel) 48 | { 49 | ArgumentOutOfRangeException.ThrowIfNegative(level, nameof(level)); 50 | ArgumentOutOfRangeException.ThrowIfGreaterThan(level, maxLevel, nameof(level)); 51 | return 1 << level; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/LibMapCommon/WebMercator.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace LibMapCommon; 4 | 5 | /// 6 | /// A Web Mercator coordinate 7 | /// 8 | public readonly struct WebMercator : IEquatable, ICoordinate 9 | { 10 | public static double Equator => 40075016.68557849; 11 | public static int EpsgNumber => 3857; 12 | 13 | private readonly double _Y; 14 | private readonly double _X; 15 | 16 | /// The X coordinate (meters) 17 | public double X => _X; 18 | /// The Y coordinate (meters) 19 | public double Y => _Y; 20 | 21 | /// 22 | /// Initialize a new instance. 23 | /// 24 | /// The Web Mercator's X coordinate 25 | /// The Web Mercator's Y coordinate 26 | /// The abs() > /2 or abs() > /2 27 | public WebMercator(double x, double y) 28 | { 29 | ArgumentOutOfRangeException.ThrowIfGreaterThan(double.Abs(x), Equator / 2, nameof(x)); 30 | ArgumentOutOfRangeException.ThrowIfGreaterThan(double.Abs(y), Equator / 2, nameof(y)); 31 | 32 | _X = x; 33 | _Y = y; 34 | } 35 | 36 | /// 37 | /// converts the Web Mercator coordinate to a WGS 84 geographic coordinate. 38 | /// 39 | public Wgs1984 ToWgs1984() 40 | { 41 | var longitude = X * 360 / Equator; 42 | var latitude = double.Atan(double.Exp(Y * 2 * double.Pi / Equator)) * 360 / double.Pi - 90; 43 | 44 | return new Wgs1984(latitude, longitude); 45 | } 46 | 47 | public override string ToString() => $"{X:F2}, {Y:F2}"; 48 | public bool Equals(WebMercator other) 49 | => other.X == X && other.Y == Y; 50 | public override int GetHashCode() 51 | => HashCode.Combine(_X, _Y); 52 | public override bool Equals([NotNullWhen(true)] object? obj) 53 | => obj is WebMercator other && Equals(other); 54 | 55 | public static WebMercator FromWgs84(Wgs1984 wgs1984) => wgs1984.ToWebMercator(); 56 | } 57 | -------------------------------------------------------------------------------- /src/LibMapCommon/Wgs1984.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace LibMapCommon; 5 | 6 | /// 7 | /// A WGS 1984 geographic coordinate 8 | /// 9 | [TypeConverter(typeof(Wgs1984TypeConverter))] 10 | public readonly struct Wgs1984 : IEquatable, ICoordinate 11 | { 12 | public static double Equator => 360d; 13 | public static int EpsgNumber => 4326; 14 | 15 | private readonly double _Y; 16 | private readonly double _X; 17 | 18 | public double X => _X; 19 | public double Y => _Y; 20 | 21 | /// The 's longitude 22 | public double Longitude => _X; 23 | /// The 's latitude 24 | public double Latitude => _Y; 25 | /// Indicates whether this instance is a valid geographic coordinate 26 | public readonly bool IsValidGeographicCoordinate => Math.Abs(Latitude) <= 90 && Math.Abs(Longitude) <= 180; 27 | 28 | 29 | /// 30 | /// Gets the containing this at a specified zoom level. 31 | /// 32 | /// An type 33 | /// The 's zoom level 34 | /// 35 | public T GetTile(int level) where T : ITile 36 | { 37 | return T.GetTile(this, level); 38 | } 39 | 40 | /// 41 | /// Initialize a new instance. 42 | /// 43 | /// The geographic coordinate's longitude 44 | /// The geographic coordinate's latitude 45 | /// The abs() > 180 or abs() > 180 46 | public Wgs1984(double latitude, double longitude) 47 | { 48 | ArgumentOutOfRangeException.ThrowIfGreaterThan(Math.Abs(latitude), 180, nameof(latitude)); 49 | ArgumentOutOfRangeException.ThrowIfGreaterThan(Math.Abs(longitude), 180, nameof(longitude)); 50 | _Y = latitude; 51 | _X = longitude; 52 | } 53 | 54 | public override string ToString() => ToString(CoordinateFormat.DecimalDegrees); 55 | 56 | public string ToString(CoordinateFormat numberFormat) 57 | { 58 | if (!Enum.IsDefined(numberFormat)) 59 | throw new ArgumentOutOfRangeException(nameof(numberFormat), $"Enum value ({numberFormat}) is not defined"); 60 | 61 | return GetCoordinate(Latitude, ['S', 'N'], numberFormat) 62 | + ", " 63 | + GetCoordinate(Longitude, ['W', 'E'], numberFormat); 64 | } 65 | 66 | private static string GetCoordinate(double coordinate, char[]? negPos, CoordinateFormat numberFormat) 67 | { 68 | if (numberFormat is CoordinateFormat.D_DecimalMins or CoordinateFormat.DM_DecimalSecs && negPos != null) 69 | { 70 | int sign = Math.Sign(coordinate); 71 | char direction = negPos[sign == -1 ? 0 : 1]; 72 | coordinate *= sign; 73 | double degrees = (int)coordinate; 74 | double minutes = (coordinate - degrees) * 60; 75 | 76 | if (numberFormat is CoordinateFormat.D_DecimalMins) 77 | return $"{degrees}°{minutes:F3}'{direction}"; 78 | 79 | double seconds = (minutes - (int)minutes) * 60; 80 | return $"{degrees}°{(int)minutes}'{seconds:F2}\"{direction}"; 81 | 82 | } 83 | else 84 | return $"{coordinate:F6}°"; 85 | } 86 | 87 | /// 88 | /// converts the WGS 84 geographic coordinate to a Web Mercator coordinate. 89 | /// 90 | public WebMercator ToWebMercator() 91 | { 92 | ArgumentOutOfRangeException.ThrowIfGreaterThan(Math.Abs(Latitude), 85.05, nameof(Latitude)); 93 | ArgumentOutOfRangeException.ThrowIfGreaterThan(Math.Abs(Longitude), 180, nameof(Longitude)); 94 | 95 | //https://gis.stackexchange.com/questions/17336/transforming-epsg3857-to-epsg4326 96 | //https://gis.stackexchange.com/questions/153839/how-to-transform-epsg3857-to-tile-pixel-coordinates-at-zoom-factor-0 97 | 98 | var x = Longitude * WebMercator.Equator / 360; 99 | var y = Math.Log(Math.Tan((90 + Latitude) * Math.PI / 360)) / (Math.PI / 180) * WebMercator.Equator / 360; 100 | return new WebMercator(x, y); 101 | } 102 | 103 | public bool Equals(Wgs1984 other) 104 | => Latitude == other.Latitude && Longitude == other.Longitude; 105 | public override int GetHashCode() 106 | => HashCode.Combine(_X, _Y); 107 | public override bool Equals([NotNullWhen(true)] object? obj) 108 | => obj is Wgs1984 other && Equals(other); 109 | 110 | public static Wgs1984 FromWgs84(Wgs1984 wgs1984) => wgs1984; 111 | } 112 | 113 | public enum CoordinateFormat 114 | { 115 | //Decimal degrees 116 | DecimalDegrees, 117 | //Degrees, decimal minutes 118 | D_DecimalMins, 119 | //Degrees, minutes, seconds 120 | DM_DecimalSecs, 121 | } 122 | -------------------------------------------------------------------------------- /test/GEHistoricalImageryTest/GEHistoricalImageryTest.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0-windows 5 | enable 6 | win-x64 7 | enable 8 | 9 | false 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/GEHistoricalImageryTest/RectangleTests.cs: -------------------------------------------------------------------------------- 1 | using LibGoogleEarth; 2 | using LibMapCommon; 3 | 4 | namespace GEHistoricalImageryTest; 5 | 6 | [TestClass] 7 | public class RectangleTests 8 | { 9 | [TestMethod] 10 | public void WrapAroundRectangle() 11 | { 12 | var ll = new Wgs1984(-90, 0); 13 | var ur = new Wgs1984(90, -0.0000001); 14 | 15 | var rec = new Rectangle(ll, ur); 16 | for (int i = 1; i <= KeyholeTile.MaxLevel; i++) 17 | { 18 | var numTiles = 1 << i; 19 | rec.GetNumRowsAndColumns(i, out var nRows, out var nColumns); 20 | Assert.AreEqual(numTiles, nColumns); 21 | Assert.AreEqual(numTiles / 2 + 1, nRows); 22 | } 23 | } 24 | 25 | [DataTestMethod] 26 | //Valid web mercater coordinates, but invalid geographic coordinates 27 | [DataRow(0, 0, 180, 0)] 28 | [DataRow(180, 0, 0, 0)] 29 | [DataRow(0, 0, 90 + double.Epsilon, 0)] 30 | [DataRow(90 + double.Epsilon, 0, 0, 0)] 31 | //Invalid regions 32 | [DataRow(0, 0, 0, 0)] // zero area 33 | [DataRow(0, -10, 0, 10)] //zero height 34 | [DataRow(-10, 0, 10, 0)] //zero width 35 | [DataRow(1, -10, 0, 10)] //negative height (negative width is allowed for wrapping around 180/-180) 36 | public void InvalidRectangles(double ll_lat, double ll_long, double ur_lat, double ur_long) 37 | { 38 | var ll = new Wgs1984(ll_lat, ll_long); 39 | var ur = new Wgs1984(ur_lat, ur_long); 40 | Assert.ThrowsException(() => new Rectangle(ll, ur)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/LibGoogleEarthTest/CoordinateTests.cs: -------------------------------------------------------------------------------- 1 | using LibGoogleEarth; 2 | using LibMapCommon; 3 | 4 | namespace LibGoogleEarthTest; 5 | 6 | [TestClass] 7 | public class CoordinateTests 8 | { 9 | [DataTestMethod] 10 | [DataRow(0, 0, 0, 0, 0)] 11 | [DataRow(0, 0, 1, 1, 1)] 12 | [DataRow(0, 180, 1, 1, 1)] 13 | [DataRow(0.00000001, 0, 1, 1, 1)] 14 | [DataRow(-0.00000001, 0, 1, 0, 1)] 15 | [DataRow(90, 0, 1, 1, 1)] 16 | [DataRow(0, 0.00000001, 1, 1, 1)] 17 | [DataRow(0, -0.00000001, 1, 1, 0)] 18 | public void GetTile(double lat, double lon, int zoom, int expectedRow, int expectedColumn) 19 | { 20 | var c = new Wgs1984(lat, lon); 21 | var tile = c.GetTile(zoom); 22 | 23 | Assert.AreEqual(zoom, tile.Level); 24 | Assert.AreEqual(expectedRow, tile.Row); 25 | Assert.AreEqual(expectedColumn, tile.Column); 26 | } 27 | 28 | [DataTestMethod] 29 | [DataRow(-1)] 30 | [DataRow(KeyholeTile.MaxLevel + 1)] 31 | public void GetTileFail(int zoom) 32 | { 33 | var c = new Wgs1984(0, 0); 34 | Assert.ThrowsException(() => c.GetTile(zoom)); 35 | } 36 | 37 | [DataTestMethod] 38 | [DataRow(200, 0)] 39 | [DataRow(0, 200)] 40 | [DataRow(180.00000001, 0)] 41 | [DataRow(0, 180.00000001)] 42 | public void InvalidCoordinate(double lat, double lon) 43 | { 44 | Assert.ThrowsException(() => new Wgs1984(lat, lon)); 45 | } 46 | 47 | [DataTestMethod] 48 | //Valid web mercater coordinates, but invalid geographic coordinates 49 | [DataRow(180, 0)] 50 | [DataRow(90.00000001, 0)] 51 | public void InvalidGeographicCoordinate(double lat, double lon) 52 | { 53 | Assert.IsFalse(new Wgs1984(lat, lon).IsValidGeographicCoordinate); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/LibGoogleEarthTest/KeyholeTileTests.cs: -------------------------------------------------------------------------------- 1 | using LibGoogleEarth; 2 | 3 | namespace LibGoogleEarthTest; 4 | 5 | [TestClass] 6 | public class KeyholeTileTests 7 | { 8 | private const int MAX_ROX_COL_SZ = (1 << KeyholeTile.MaxLevel) - 1; 9 | 10 | [DataTestMethod] 11 | [DataRow(0, 0, 0, "0")] 12 | [DataRow(0, 0, 1, "00")] 13 | [DataRow(0, 1, 1, "01")] 14 | [DataRow(1, 1, 1, "02")] 15 | [DataRow(1, 0, 1, "03")] 16 | [DataRow(1, 0, 1, "03")] 17 | [DataRow((1 << 10) - 1, 0, 10, "03333333333")] 18 | [DataRow(0, (1 << 10) - 1, 10, "01111111111")] 19 | [DataRow((1 << 10) - 1, (1 << 10) - 1, 10, "02222222222")] 20 | [DataRow(MAX_ROX_COL_SZ, 0, KeyholeTile.MaxLevel, "0333333333333333333333333333333")] 21 | [DataRow(0, MAX_ROX_COL_SZ, KeyholeTile.MaxLevel, "0111111111111111111111111111111")] 22 | [DataRow(MAX_ROX_COL_SZ, MAX_ROX_COL_SZ, KeyholeTile.MaxLevel, "0222222222222222222222222222222")] 23 | 24 | /* 25 | c0 c1 26 | |-----|-----| 27 | r1 | 3 | 2 | 28 | |-----|-----| 29 | r0 | 0 | 1 | 30 | |-----|-----| 31 | */ 32 | [DataRow(0b0111011011, 0b1101101101, 10, "01232132132")] 33 | 34 | public void ValidTiles(int row, int col, int zoom, string qtp) 35 | { 36 | var tile = new KeyholeTile(row, col, zoom); 37 | Assert.AreEqual(qtp, tile.Path); 38 | Assert.AreEqual(zoom, tile.Level); 39 | } 40 | private double RowColToLatLong(double rowCol, int level) 41 | => rowCol * 360d / (1 << level) - 180; 42 | 43 | [DataTestMethod] 44 | [DataRow(0, 0, -1)] 45 | [DataRow(0, -1, 0)] 46 | [DataRow(-1, 0, 0)] 47 | [DataRow(1 << 10, 0, 10)] 48 | [DataRow(0, 1 << 10, 10)] 49 | [DataRow(0, 0, 31)] 50 | 51 | public void TilesOutOfRange(int row, int col, int zoom) 52 | { 53 | Assert.ThrowsException(() => new KeyholeTile(row, col, zoom)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/LibGoogleEarthTest/LibGoogleEarthTest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/LibGoogleEarthTest/QtPathTest.cs: -------------------------------------------------------------------------------- 1 | using LibGoogleEarth; 2 | using System.Collections.ObjectModel; 3 | using System.Text.Json; 4 | 5 | namespace LibGoogleEarthTest; 6 | 7 | [TestClass] 8 | public class QtPathTest 9 | { 10 | #region Pre-Computed Values 11 | private static readonly ReadOnlyDictionary SubIndexDict; 12 | private static readonly ReadOnlyDictionary RootIndexDict; 13 | #endregion 14 | 15 | static QtPathTest() 16 | { 17 | var projectDir = Path.Combine(".", "..", "..", ".."); 18 | var rootIndicesPath = Path.Combine(projectDir, "RootIndexDictionary.json"); 19 | var subIndicesPath = Path.Combine(projectDir, "SubIndexDictionary.json"); 20 | RootIndexDict = JsonSerializer.Deserialize>(File.OpenRead(rootIndicesPath))!.AsReadOnly(); 21 | SubIndexDict = JsonSerializer.Deserialize>(File.OpenRead(subIndicesPath))!.AsReadOnly(); 22 | } 23 | 24 | [DataTestMethod] 25 | [DataRow("0000")] 26 | [DataRow("00000000")] 27 | [DataRow("000000000000")] 28 | [DataRow("0000000000000000")] 29 | [DataRow("00000000000000000000")] 30 | [DataRow("000000000000000000000000")] 31 | public void SubIndices(string qtIndex) 32 | { 33 | 34 | for (int i = 0; i < 4; i++) 35 | { 36 | var iStr = i.ToString(); 37 | var qtPath = qtIndex + iStr; 38 | var p = new KeyholeTile(qtPath); 39 | Assert.AreEqual(SubIndexDict[iStr], p.SubIndex); 40 | Assert.AreEqual(qtPath, p.Path); 41 | for (int j = 0; j < 4; j++) 42 | { 43 | var jStr = iStr + j; 44 | qtPath = qtIndex + jStr; 45 | p = new KeyholeTile(qtPath); 46 | Assert.AreEqual(SubIndexDict[jStr], p.SubIndex); 47 | Assert.AreEqual(qtPath, p.Path); 48 | for (int k = 0; k < 4; k++) 49 | { 50 | var kStr = jStr + k; 51 | qtPath = qtIndex + kStr; 52 | p = new KeyholeTile(qtPath); 53 | Assert.AreEqual(SubIndexDict[kStr], p.SubIndex); 54 | Assert.AreEqual(qtPath, p.Path); 55 | for (int l = 0; l < 4; l++) 56 | { 57 | var lStr = kStr + l; 58 | qtPath = qtIndex + lStr; 59 | p = new KeyholeTile(qtPath); 60 | Assert.AreEqual(SubIndexDict[lStr], p.SubIndex); 61 | Assert.AreEqual(qtPath, p.Path); 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | 69 | [TestMethod] 70 | public void RootIndex() 71 | { 72 | for (int i = 0; i < 4; i++) 73 | { 74 | var iStr = "0" + i.ToString(); 75 | var p = new KeyholeTile(iStr); 76 | Assert.AreEqual(RootIndexDict[iStr], p.SubIndex); 77 | Assert.AreEqual(iStr, p.Path); 78 | for (int j = 0; j < 4; j++) 79 | { 80 | var jStr = iStr + j; 81 | p = new KeyholeTile(jStr); 82 | Assert.AreEqual(RootIndexDict[jStr], p.SubIndex); 83 | Assert.AreEqual(jStr, p.Path); 84 | for (int k = 0; k < 4; k++) 85 | { 86 | var kStr = jStr + k; 87 | p = new KeyholeTile(kStr); 88 | Assert.AreEqual(RootIndexDict[kStr], p.SubIndex); 89 | Assert.AreEqual(kStr, p.Path); 90 | } 91 | } 92 | } 93 | } 94 | 95 | [TestMethod] 96 | public void RootIndex2() 97 | { 98 | var p = new KeyholeTile("0"); 99 | Assert.IsTrue(p.IsRoot); 100 | Assert.AreEqual(0, p.SubIndex); 101 | Assert.AreEqual(p.Level, 0); 102 | Assert.AreEqual("0", p.Path); 103 | } 104 | 105 | [DataTestMethod] 106 | [DataRow("1")] 107 | [DataRow("01234")] 108 | [DataRow("012334")] 109 | [DataRow("0000134")] 110 | [DataRow("00001304")] 111 | [DataRow("10001304")] 112 | [DataRow(" 02322")] 113 | [DataRow("")] 114 | public void BadPaths(string quadTreePath) 115 | { 116 | Assert.ThrowsException(() => new KeyholeTile(quadTreePath)); 117 | } 118 | 119 | [TestMethod] 120 | public void NullPath() 121 | => Assert.ThrowsException(() => new KeyholeTile(null!)); 122 | 123 | [TestMethod] 124 | public void EnumerateIndices() 125 | { 126 | for (int level = 0; level <= KeyholeTile.MaxLevel; level++) 127 | { 128 | var qtp = RandomQuadTreePath(level + 1); 129 | 130 | var p = new KeyholeTile(qtp); 131 | Assert.AreEqual(qtp, p.Path); 132 | Assert.AreEqual(level, p.Level); 133 | 134 | int index = 0; 135 | foreach (var qtpIndex in p.Indices) 136 | { 137 | var expected = qtp.Substring(0, index += 4); 138 | Assert.AreEqual(expected, qtpIndex.Path); 139 | } 140 | 141 | var diff = qtp.Length - index; 142 | var expectedDiff = ((qtp.Length - 1) % 4) + 1; 143 | Assert.AreEqual(expectedDiff, diff); 144 | } 145 | } 146 | 147 | private static readonly Random random = new(); 148 | private static string RandomQuadTreePath(int length) 149 | { 150 | char[] path = new char[length]; 151 | path[0] = '0'; 152 | 153 | for (int i = 1; i < length; i++) 154 | path[i] = (char)random.Next(0x30, 0x34); 155 | 156 | return new string(path); 157 | } 158 | } -------------------------------------------------------------------------------- /test/LibGoogleEarthTest/RootIndexDictionary.json: -------------------------------------------------------------------------------- 1 | {"0":0,"00":1,"01":2,"02":3,"03":4,"000":5,"001":6,"002":7,"003":8,"010":9,"011":10,"012":11,"013":12,"020":13,"021":14,"022":15,"023":16,"030":17,"031":18,"032":19,"033":20,"0000":21,"0001":22,"0002":23,"0003":24,"0010":25,"0011":26,"0012":27,"0013":28,"0020":29,"0021":30,"0022":31,"0023":32,"0030":33,"0031":34,"0032":35,"0033":36,"0100":37,"0101":38,"0102":39,"0103":40,"0110":41,"0111":42,"0112":43,"0113":44,"0120":45,"0121":46,"0122":47,"0123":48,"0130":49,"0131":50,"0132":51,"0133":52,"0200":53,"0201":54,"0202":55,"0203":56,"0210":57,"0211":58,"0212":59,"0213":60,"0220":61,"0221":62,"0222":63,"0223":64,"0230":65,"0231":66,"0232":67,"0233":68,"0300":69,"0301":70,"0302":71,"0303":72,"0310":73,"0311":74,"0312":75,"0313":76,"0320":77,"0321":78,"0322":79,"0323":80,"0330":81,"0331":82,"0332":83,"0333":84} 2 | -------------------------------------------------------------------------------- /test/LibGoogleEarthTest/SubIndexDictionary.json: -------------------------------------------------------------------------------- 1 | {"0":1,"00":2,"000":6,"0000":22,"0001":23,"0002":24,"0003":25,"001":7,"0010":26,"0011":27,"0012":28,"0013":29,"002":8,"0020":30,"0021":31,"0022":32,"0023":33,"003":9,"0030":34,"0031":35,"0032":36,"0033":37,"01":3,"010":10,"0100":38,"0101":39,"0102":40,"0103":41,"011":11,"0110":42,"0111":43,"0112":44,"0113":45,"012":12,"0120":46,"0121":47,"0122":48,"0123":49,"013":13,"0130":50,"0131":51,"0132":52,"0133":53,"02":4,"020":14,"0200":54,"0201":55,"0202":56,"0203":57,"021":15,"0210":58,"0211":59,"0212":60,"0213":61,"022":16,"0220":62,"0221":63,"0222":64,"0223":65,"023":17,"0230":66,"0231":67,"0232":68,"0233":69,"03":5,"030":18,"0300":70,"0301":71,"0302":72,"0303":73,"031":19,"0310":74,"0311":75,"0312":76,"0313":77,"032":20,"0320":78,"0321":79,"0322":80,"0323":81,"033":21,"0330":82,"0331":83,"0332":84,"0333":85,"1":86,"10":87,"100":91,"1000":107,"1001":108,"1002":109,"1003":110,"101":92,"1010":111,"1011":112,"1012":113,"1013":114,"102":93,"1020":115,"1021":116,"1022":117,"1023":118,"103":94,"1030":119,"1031":120,"1032":121,"1033":122,"11":88,"110":95,"1100":123,"1101":124,"1102":125,"1103":126,"111":96,"1110":127,"1111":128,"1112":129,"1113":130,"112":97,"1120":131,"1121":132,"1122":133,"1123":134,"113":98,"1130":135,"1131":136,"1132":137,"1133":138,"12":89,"120":99,"1200":139,"1201":140,"1202":141,"1203":142,"121":100,"1210":143,"1211":144,"1212":145,"1213":146,"122":101,"1220":147,"1221":148,"1222":149,"1223":150,"123":102,"1230":151,"1231":152,"1232":153,"1233":154,"13":90,"130":103,"1300":155,"1301":156,"1302":157,"1303":158,"131":104,"1310":159,"1311":160,"1312":161,"1313":162,"132":105,"1320":163,"1321":164,"1322":165,"1323":166,"133":106,"1330":167,"1331":168,"1332":169,"1333":170,"2":171,"20":172,"200":176,"2000":192,"2001":193,"2002":194,"2003":195,"201":177,"2010":196,"2011":197,"2012":198,"2013":199,"202":178,"2020":200,"2021":201,"2022":202,"2023":203,"203":179,"2030":204,"2031":205,"2032":206,"2033":207,"21":173,"210":180,"2100":208,"2101":209,"2102":210,"2103":211,"211":181,"2110":212,"2111":213,"2112":214,"2113":215,"212":182,"2120":216,"2121":217,"2122":218,"2123":219,"213":183,"2130":220,"2131":221,"2132":222,"2133":223,"22":174,"220":184,"2200":224,"2201":225,"2202":226,"2203":227,"221":185,"2210":228,"2211":229,"2212":230,"2213":231,"222":186,"2220":232,"2221":233,"2222":234,"2223":235,"223":187,"2230":236,"2231":237,"2232":238,"2233":239,"23":175,"230":188,"2300":240,"2301":241,"2302":242,"2303":243,"231":189,"2310":244,"2311":245,"2312":246,"2313":247,"232":190,"2320":248,"2321":249,"2322":250,"2323":251,"233":191,"2330":252,"2331":253,"2332":254,"2333":255,"3":256,"30":257,"300":261,"3000":277,"3001":278,"3002":279,"3003":280,"301":262,"3010":281,"3011":282,"3012":283,"3013":284,"302":263,"3020":285,"3021":286,"3022":287,"3023":288,"303":264,"3030":289,"3031":290,"3032":291,"3033":292,"31":258,"310":265,"3100":293,"3101":294,"3102":295,"3103":296,"311":266,"3110":297,"3111":298,"3112":299,"3113":300,"312":267,"3120":301,"3121":302,"3122":303,"3123":304,"313":268,"3130":305,"3131":306,"3132":307,"3133":308,"32":259,"320":269,"3200":309,"3201":310,"3202":311,"3203":312,"321":270,"3210":313,"3211":314,"3212":315,"3213":316,"322":271,"3220":317,"3221":318,"3222":319,"3223":320,"323":272,"3230":321,"3231":322,"3232":323,"3233":324,"33":260,"330":273,"3300":325,"3301":326,"3302":327,"3303":328,"331":274,"3310":329,"3311":330,"3312":331,"3313":332,"332":275,"3320":333,"3321":334,"3322":335,"3323":336,"333":276,"3330":337,"3331":338,"3332":339,"3333":340} --------------------------------------------------------------------------------