diff --git a/CLAUDE.md b/CLAUDE.md index ce5f63f..1d23814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Source layout under `src/OrthoStreamXP/`. Sub-folders are for navigation only: * - `Aircraft/`: `AircraftPositionListener` (X-Plane UDP), `ViewTracker`, `SceneryLoads`. - `Colour/`: `ColourMatcher` (evens patchwork), `SeasonClock` (seasonal tint by simulated date/location). - `Textures/` — imagery to DDS: `TextureBuilder` (fetch → stitch → resize → mipmaps → BC1 encode → atomic write; own encoder, SkiaSharp is decode-only), `TextureQuality`, `Images` at the root; `Tiles/` (`TileSource`/`TileStore`: download + cache), `Dds/`, `Scheduling/` (priority gates), `Seasons/`, `Colour/` (`ColourMatch`, `Exposure`, `ColourTable` = the shared BC1 lookup-table recolouring, `PhotoColours` = match → season → exposure as X-Plane reads, also used by the Settings preview). -- `Setup/` — `Settings/` (`SettingsDocument`, ini under `%LOCALAPPDATA%\OrthoStreamXP`, `QualityPreset`, `ConnectionTest`), `Cache/` (inventory, areas, eviction), `Installation/` (`AppPaths`, `AppUpdates`, `LegacyNames` migrating older settings/cache locations, `SystemCheck`, Velopack hooks). +- `Setup/` — `Settings/` (`SettingsDocument`, ini under `%LOCALAPPDATA%\OrthoStreamXP`, `QualityPreset`, `ConnectionTest`), `Cache/` (inventory, areas, eviction), `Packages/` (downloads AutoOrtho's mesh regions from GitHub into the packages folder as `OSXP_*` packages: `MeshCatalog`, `AssetDownload`, `ZipUnpacker`, `OverlayPackage`, `RegionInstaller`), `Installation/` (`AppPaths`, `AppUpdates`, `LegacyNames` migrating older settings/cache locations, `SystemCheck`, Velopack hooks). - `Tuning/` — flight reports, facts and the preset/setting recommendations. - Logs go to `%LOCALAPPDATA%\OrthoStreamXP\logs`. diff --git a/README.md b/README.md index 87e1a4b..5226af7 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,17 @@ Before installing OrthoStreamXP, make sure you have: - **Windows 10 or 11 (64-bit)** - **X-Plane 12** - **WinFsp**, a free Windows component used to make the streamed scenery available to X-Plane. The setup process checks for it and provides a download link. -- **Existing scenery packages** for the regions you want to fly over. +- **Scenery packages** for the regions you want to fly over: a region's mesh downloaded from within OrthoStreamXP, or your own. - **An internet connection** for downloading new scenery. - **Disk space for the scenery cache** — 25 GB is a reasonable starting point; more space lets you keep more areas available. - A graphics card with enough memory for your chosen quality preset. The installer also installs the .NET 10 desktop runtime if it is missing. -**Important:** OrthoStreamXP streams imagery; it does not create terrain meshes or provide ready-made scenery packages. You must supply compatible base packages from a supported scenery source. +**Important:** OrthoStreamXP streams imagery onto scenery packages; it does not create terrain meshes itself. There are two ways to get them: -Compatible packages can come from tools such as Ortho4XP, OrthoStudio XP, AutoOrtho, and XPME. You can select an existing package folder during setup. +1. **Download a region from within OrthoStreamXP:** a whole region's mesh (Europe, North America, ...), during setup or later from the **Packages** page. +2. **Use your own packages:** compatible packages can come from tools such as Ortho4XP, OrthoStudio XP, AutoOrtho, and XPME. You can select an existing package folder during setup. ## Installation @@ -116,7 +117,7 @@ Water, roads, buildings, rock, and sand retain their original colours. Seasonal OrthoStreamXP is currently in beta. Please keep the following in mind: - **Windows only:** the application depends on WinFsp. -- **You supply the scenery:** compatible base packages are required for the regions you want to fly. +- **Scenery packages are required:** download a region's mesh (6 to 33 GB per region) from the Packages page, or bring your own. - **Internet required for new areas:** scenery loads from the internet on first use; subsequent flights can reuse cached imagery. - **Imagery varies by location:** quality, capture date, colours, and transitions between imagery sources may differ. - **Performance depends on your setup:** connection speed, disk performance, and graphics memory affect the experience. diff --git a/build-user-guide.ps1 b/build-user-guide.ps1 index d03e52a..438bee1 100644 --- a/build-user-guide.ps1 +++ b/build-user-guide.ps1 @@ -24,7 +24,7 @@ if (-not $browser) { throw 'Google Chrome or Microsoft Edge is needed to print t # The order of the guide's contents (docs\README.md). The pages keep their names so that pandoc's --file-scope # turns the links between them (settings.md#quality) into links inside the PDF. -$pages = 'README', 'installation', 'first-setup', 'flying', 'settings', 'cache', 'troubleshooting', 'faq' +$pages = 'README', 'installation', 'first-setup', 'flying', 'settings', 'cache', 'packages', 'troubleshooting', 'faq' $work = 'artifacts\user-guide' if (Test-Path $work) { Remove-Item $work -Recurse -Force } New-Item -ItemType Directory $work | Out-Null diff --git a/docs/OrthoStreamXP-UserGuide.pdf b/docs/OrthoStreamXP-UserGuide.pdf index 5fb6a66..325274e 100644 Binary files a/docs/OrthoStreamXP-UserGuide.pdf and b/docs/OrthoStreamXP-UserGuide.pdf differ diff --git a/docs/README.md b/docs/README.md index a6831c2..92d1d55 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,13 +11,14 @@ OrthoStreamXP adds real-world aerial photos to your X-Plane 12 scenery while you 3. [Flying](flying.md): the start order, the Home page, and the report you get after each flight. 4. [Settings](settings.md): quality, folders, downloads, colours, seasons and exposure. 5. [Cache](cache.md): how much disk space the scenery uses and how to free it. -6. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug. -7. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho. +6. [Packages](packages.md): downloading the ground mesh of a whole region, the quickest way to get scenery packages. +7. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug. +8. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho. ## The short version 1. Install [WinFsp](https://winfsp.dev/rel/), then OrthoStreamXP, then restart your computer. -2. Open OrthoStreamXP and finish the setup: X-Plane folder, scenery packages folder, cache folder, quality. +2. Open OrthoStreamXP and finish the setup: X-Plane folder, scenery packages (download a region there, or choose the folder of your own), cache folder, quality. 3. Every time you fly, click **Start and fly** in OrthoStreamXP. It starts the scenery first and then X-Plane. 4. Load a flight. A new area takes a few minutes the first time; after that it comes from the cache. diff --git a/docs/faq.md b/docs/faq.md index 13f9d68..08db240 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -14,7 +14,7 @@ It is free and open source, and it goes up to imagery zoom level 19 (the **Extra ### Does it make the terrain (the mesh) too? -No. OrthoStreamXP only provides the photos. The mesh, the water masks and the coastlines come from scenery packages you already have, made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME. See [Installation › What you need](installation.md#what-you-need). +No, OrthoStreamXP does not make the mesh: it provides the photos. The mesh, the water masks and the coastlines come from scenery packages. Download a whole region's mesh in OrthoStreamXP itself (see [Packages](packages.md)), or use packages you already have, made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME. See [Installation › What you need](installation.md#what-you-need). ### Why can't it use the mesh from X-Plane's default scenery? diff --git a/docs/first-setup.md b/docs/first-setup.md index 7f257c0..e3591d6 100644 --- a/docs/first-setup.md +++ b/docs/first-setup.md @@ -25,6 +25,22 @@ If yours is not listed, click **Choose another folder…** and choose the folder ## 3. Scenery +OrthoStreamXP adds the photos to **scenery packages**: the ground of an area, the shape of the terrain cut in 1° squares. There are two ways to get them. + +### Option 1: download a region in OrthoStreamXP + +The simplest way, if you have no scenery packages yet. Under **No scenery packages yet?**, click **Download a region…**. If you already chose a folder without packages, the hint turns red and **Download a region now** opens the same list. + +![Setup step 3: a folder without scenery packages](images/setup-scenery-empty.png) + +The list of regions opens: click **Install** next to the region you fly in. The progress shows under it, and you can stop and resume the download. When it finishes, the region's folder is chosen as your scenery packages folder and **Next** becomes available. + +![Setup step 3: the regions to download](images/setup-scenery-download.png) + +See [Packages](packages.md) for the sizes, the roads, forests and buildings, and how to update or delete a region later. You can download more regions at any time on the **Packages** page. + +### Option 2: use your own scenery packages + ![Setup step 3: Scenery packages](images/setup-scenery.png) Click **Choose a folder…** and pick the **parent folder** (in the example below it's "OrthoPackages") that holds your scenery packages, not one of the packages (not "Europe" or "Asia"). Below the folder, OrthoStreamXP lists the packages it found. @@ -43,9 +59,10 @@ D:\OrthoPackages\ ``` - Your packages stay unchanged on disk. While OrthoStreamXP runs, they appear in X-Plane's `Custom Scenery` folder with the photos added. When it stops, they disappear from there again. -- If the folder has no packages, the hint turns red: *That folder has no scenery packages*. Check that each package has an `Earth nav data` folder inside it. +- If the hint turns red, *That folder has no scenery packages*, you probably picked one package instead of the folder that holds them: check that each package has an `Earth nav data` folder inside it. - If X-Plane's `Custom Scenery` already contains a folder with the same name as one of your packages, you get a warning. Usually another streaming tool still shows that package there; close that tool before you start OrthoStreamXP and check your scenery_packs.ini file. + ## 4. Cache ![Setup step 4: Cache folder](images/setup-cache.png) diff --git a/docs/images/about.png b/docs/images/about.png index a7d1c8c..1535697 100644 Binary files a/docs/images/about.png and b/docs/images/about.png differ diff --git a/docs/images/packages-downloading.png b/docs/images/packages-downloading.png new file mode 100644 index 0000000..ba56d10 Binary files /dev/null and b/docs/images/packages-downloading.png differ diff --git a/docs/images/packages.png b/docs/images/packages.png new file mode 100644 index 0000000..3146651 Binary files /dev/null and b/docs/images/packages.png differ diff --git a/docs/images/settings-download.png b/docs/images/settings-download.png index 9110e83..dc6f63a 100644 Binary files a/docs/images/settings-download.png and b/docs/images/settings-download.png differ diff --git a/docs/images/settings-folders.png b/docs/images/settings-folders.png index e060094..714391e 100644 Binary files a/docs/images/settings-folders.png and b/docs/images/settings-folders.png differ diff --git a/docs/images/settings-other.png b/docs/images/settings-other.png index 385e519..e16cd98 100644 Binary files a/docs/images/settings-other.png and b/docs/images/settings-other.png differ diff --git a/docs/images/settings-quality.png b/docs/images/settings-quality.png index 7440a79..cfd4d10 100644 Binary files a/docs/images/settings-quality.png and b/docs/images/settings-quality.png differ diff --git a/docs/images/settings-tuning.png b/docs/images/settings-tuning.png index 0bbbc47..33efb52 100644 Binary files a/docs/images/settings-tuning.png and b/docs/images/settings-tuning.png differ diff --git a/docs/images/setup-scenery-download.png b/docs/images/setup-scenery-download.png new file mode 100644 index 0000000..2c840ee Binary files /dev/null and b/docs/images/setup-scenery-download.png differ diff --git a/docs/images/setup-scenery-empty.png b/docs/images/setup-scenery-empty.png new file mode 100644 index 0000000..7e79b63 Binary files /dev/null and b/docs/images/setup-scenery-empty.png differ diff --git a/docs/images/setup-scenery.png b/docs/images/setup-scenery.png index a729b64..5f91814 100644 Binary files a/docs/images/setup-scenery.png and b/docs/images/setup-scenery.png differ diff --git a/docs/installation.md b/docs/installation.md index d5984f0..ae1cc79 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,20 +6,22 @@ - **Windows 10 or 11 (64-bit).** - **X-Plane 12.** -- **Scenery packages of your own** for the regions you fly. OrthoStreamXP adds the photos to them, but it does not make the terrain (the mesh) itself. Packages made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME work. Each package is a folder that contains an `Earth nav data` folder. Put all of them in **one parent folder**, for example: - - ``` - D:\OrthoPackages\ - Europe\ - Earth nav data\ - terrain\ - ... - Asia\ - Earth nav data\ - ... - ``` - - **In this example you have to specify the "OrthoPackages" folder as a Scenery packages folder in the application. Keep in mind the "OrthoPackages" here is just for example it can be whatever folder you have. Don't put your scenery packages to the "X-Plane 12/Custom Scenery" - it has to be out of it somewhere on you PC** +- **Scenery packages** for the regions you fly: the terrain (the mesh) that OrthoStreamXP adds the photos to. Two ways to get them: + 1. **Download a region in OrthoStreamXP**, during the setup or later on the **Packages** page (Europe, North America...). Nothing to prepare beforehand; see [Packages](packages.md). + 2. **Use your own.** Packages made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME work. Each package is a folder that contains an `Earth nav data` folder. Put all of them in **one parent folder**, for example: + + ``` + D:\OrthoPackages\ + Europe\ + Earth nav data\ + terrain\ + ... + Asia\ + Earth nav data\ + ... + ``` + + **In this example you have to specify the "OrthoPackages" folder as a Scenery packages folder in the application. Keep in mind the "OrthoPackages" here is just for example it can be whatever folder you have. Don't put your scenery packages to the "X-Plane 12/Custom Scenery" - it has to be out of it somewhere on you PC** - **WinFsp**, a free Windows driver that OrthoStreamXP uses to show the scenery to X-Plane. - **An internet connection** for areas you have not flown yet. diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..ff36f5f --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,44 @@ +# Packages + +[User guide](README.md) › Packages + +OrthoStreamXP streams photos onto scenery packages: the ground's shape (the mesh) for each area. The quickest way to get them is to download the mesh of a whole region, on the **Packages** page or in the **Scenery packages** step of the setup. You can also use packages of your own, or both together. + +![The Packages page, with Australia and the Pacific installed](images/packages.png) + +The meshes are downloaded from a public collection on [GitHub](https://github.com/kubilus1/autoortho-scenery). Six regions are offered: + +| Region | Package | Download (with roads, forests and buildings) | +|---|---|---| +| Europe | `OSXP_Eur` | about 16 GB | +| North America | `OSXP_NA` | about 25 GB | +| South America | `OSXP_SA` | about 9 GB | +| Africa | `OSXP_Afr` | about 12 GB | +| Asia | `OSXP_Asi` | about 33 GB | +| Australia and the Pacific | `OSXP_AusPac` | about 6 GB | + +The page shows the exact sizes of the current release. + +## Where they go + +Each region is installed as a package of its own in your **scenery packages folder**, beside your own packages. If you download one during the setup and have not chosen a folder yet, it goes to `OrthoStreamXP\Base Packages` in your user folder, and that folder becomes your scenery packages folder. + +Your own packages stay **above** the downloaded regions in X-Plane's scenery list. Where both cover the same area, X-Plane shows yours. + +Leave about 1.7 times the download size free on the drive: the files take more room once they are unpacked. + +## Roads, forests and buildings + +A square of a downloaded mesh no longer shows X-Plane's own roads, forests and buildings. **Roads, forests and buildings** downloads them with each region, into one shared package, `OSXP_Overlays`. Turn it off if another package of yours already brings them. The choice applies to the next region you install or update. + +## Downloading, stopping and resuming + +![A region downloading](images/packages-downloading.png) + +- **Install** starts the download. While one region downloads, the others wait: one install runs at a time. The progress and speed show under the region. +- **Stop** stops it. What was downloaded is kept: **Resume** continues from there, even after OrthoStreamXP was closed. +- A region is used from OrthoStreamXP's next start, and never half-installed: it appears in your packages folder only when all of it is there. +- When a newer release comes out, the region says **Update available**. Updating downloads it again in full. +- **Delete** removes the region and its roads, forests and buildings. Another region's stay. + +You can install a new region while OrthoStreamXP runs. Updating or deleting a region needs OrthoStreamXP stopped, because X-Plane has its files open while it runs. diff --git a/mkdocs.yml b/mkdocs.yml index 61ac979..2d70127 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ nav: - flying.md - settings.md - cache.md + - packages.md - troubleshooting.md - Questions and answers: faq.md - User guide (PDF): OrthoStreamXP-UserGuide.pdf diff --git a/src/OrthoStreamXP/Gui/MainWindow.xaml b/src/OrthoStreamXP/Gui/MainWindow.xaml index d27dd0e..419800c 100644 --- a/src/OrthoStreamXP/Gui/MainWindow.xaml +++ b/src/OrthoStreamXP/Gui/MainWindow.xaml @@ -10,6 +10,7 @@ + diff --git a/src/OrthoStreamXP/Gui/Packages/InstallSlot.cs b/src/OrthoStreamXP/Gui/Packages/InstallSlot.cs new file mode 100644 index 0000000..ce2efb2 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Packages/InstallSlot.cs @@ -0,0 +1,18 @@ +namespace OrthoStreamXP.Gui; + +/// +/// One install or delete of a mesh region at a time in the whole window: the Packages page and the setup's list of regions +/// each have their own rows, and both write into the same packages folder and the one shared overlays package. +/// +internal sealed class InstallSlot +{ + private int _taken; + + /// An install or a delete is running. + public bool Busy => Volatile.Read(ref _taken) == 1; + + /// Takes the slot; false when another install or delete has it. + public bool TryTake() => Interlocked.CompareExchange(ref _taken, 1, 0) == 0; + + public void Release() => Volatile.Write(ref _taken, 0); +} diff --git a/src/OrthoStreamXP/Gui/Packages/RegionRow.cs b/src/OrthoStreamXP/Gui/Packages/RegionRow.cs new file mode 100644 index 0000000..d116041 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Packages/RegionRow.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.Windows.Input; +using OrthoStreamXP.Setup; + +namespace OrthoStreamXP.Gui; + +/// One region of the mesh on the Packages page: whether it is installed, its download, and its buttons. +internal sealed class RegionRow : ObservableObject +{ + private readonly PackagesViewModel _owner; + private InstalledRegion? _installed; + private long _partial; + private bool _busy, _confirming, _deleting; + private InstallProgress? _progress; + private string? _error; + private (long At, long Bytes)? _rateFrom; + private double _bytesPerSecond; + + public RegionRow(PackagesViewModel owner, MeshRegion region) + { + _owner = owner; + Region = region; + Install = new RelayCommand(() => _ = _owner.InstallAsync(this), () => _owner.CanInstall(this)); + Stop = new RelayCommand(_owner.StopInstall, () => Busy); + AskDelete = new RelayCommand(() => Confirming = true, () => _owner.CanDelete(this)); + CancelDelete = new RelayCommand(() => Confirming = false); + Delete = new RelayCommand(() => _ = _owner.DeleteAsync(this), () => _owner.CanDelete(this)); + } + + public MeshRegion Region { get; } + public ICommand Install { get; } + public ICommand Stop { get; } + public ICommand AskDelete { get; } + public ICommand CancelDelete { get; } + public ICommand Delete { get; } + + public string Name => Region.Name; + + public string Details => + $"{CacheInventory.FormatBytes(Region.MeshSize)} mesh" + + (Region.OverlaysSize > 0 ? $" + {CacheInventory.FormatBytes(Region.OverlaysSize)} roads, forests and buildings" : "") + + $" · installed as {Region.PackageName}"; + + public InstalledRegion? Installed + { + get => _installed; + set + { + _installed = value; + RaiseState(); + } + } + + /// Bytes an earlier, stopped install already has. + public long Partial + { + get => _partial; + set + { + _partial = value; + RaiseState(); + } + } + + public bool IsInstalled => Installed is not null; + + public bool UpToDate => Installed is { } i && i.Release == Region.Release; + + public bool Busy + { + get => _busy; + set + { + if (!Set(ref _busy, value)) return; + _rateFrom = null; + _bytesPerSecond = 0; + if (!value) _progress = null; + RaiseState(); + } + } + + public bool Confirming + { + get => _confirming; + set => Set(ref _confirming, value); + } + + public bool Deleting + { + get => _deleting; + set + { + if (Set(ref _deleting, value)) Confirming = false; + } + } + + public string? Error + { + get => _error; + set + { + Set(ref _error, value); + Raise(nameof(HasError)); + } + } + + public bool HasError => Error is not null; + + /// The install button's word: Install, Resume or Update; hidden when the region is up to date. + public string InstallLabel => IsInstalled ? "Update" : Partial > 0 ? "Resume" : "Install"; + + public bool ShowInstall => !Busy && !UpToDate; + + public string DeleteQuestion => + $"Delete the {Name} mesh ({Region.PackageName}) and its roads, forests and buildings? Downloading it again takes {CacheInventory.FormatBytes(Region.DownloadSize(true))}."; + + public string Status + { + get + { + if (Busy) + { + if (_progress is not { } p) return "Starting…"; + string rate = _bytesPerSecond > 0 ? $" · {_bytesPerSecond / (1 << 20):F1} MB/s" : ""; + return $"{p.Stage}: {CacheInventory.FormatBytes(p.Done)} of {CacheInventory.FormatBytes(p.Total)}{rate}"; + } + if (Installed is { } installed) + return UpToDate + ? $"Installed · release {installed.Release}{(installed.Overlays ? "" : ", without roads, forests and buildings")}" + : $"Update available: release {installed.Release} → {Region.Release}"; + return Partial > 0 ? $"Stopped with {CacheInventory.FormatBytes(Partial)} downloaded: Resume goes on from there" : "Not installed"; + } + } + + /// 0 to 100. + public double Percent => _progress is { Total: > 0 } p ? 100.0 * p.Done / p.Total : 0; + + public void Report(InstallProgress progress) + { + _progress = progress; + long now = Stopwatch.GetTimestamp(); + if (_rateFrom is not { } from) _rateFrom = (now, progress.Done); + else if (Stopwatch.GetElapsedTime(from.At, now) is { TotalSeconds: >= 3 } span) + { + _bytesPerSecond = Math.Max(0, (progress.Done - from.Bytes) / span.TotalSeconds); + _rateFrom = (now, progress.Done); + } + Raise(nameof(Status)); + Raise(nameof(Percent)); + } + + private void RaiseState() + { + Raise(nameof(IsInstalled)); + Raise(nameof(UpToDate)); + Raise(nameof(InstallLabel)); + Raise(nameof(ShowInstall)); + Raise(nameof(Status)); + Raise(nameof(Percent)); + RelayCommand.Refresh(); + } +} diff --git a/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs index 02d65d6..06d5873 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs @@ -22,6 +22,9 @@ internal sealed class WindowPreferences /// Start the mount as soon as the window opens. public bool AutoStart { get; set; } + /// Download a mesh region's roads, forests and buildings (its overlays) with it. + public bool DownloadOverlays { get; set; } = true; + public static WindowPreferences Load() { try @@ -61,12 +64,15 @@ public MainViewModel() Home = new HomeViewModel(this); Settings = new SettingsViewModel(this); Cache = new CacheViewModel(this); + Packages = new PackagesViewModel(Mount, Preferences, MeshHttp, MeshInstalls, + () => SettingsDocument.Load(SettingsDocument.DefaultPath).Get("base-packages")); About = new AboutViewModel(); Navigation = [ new NavItem("Home", "", Home), new NavItem("Settings", "", Settings), new NavItem("Cache", "", Cache), + new NavItem("Packages", "", Packages), new NavItem("Log", "", LogPage), new NavItem("About", "", About), ]; @@ -84,6 +90,12 @@ public MainViewModel() public HomeViewModel Home { get; } public SettingsViewModel Settings { get; } public CacheViewModel Cache { get; } + /// The regions of the mesh to download into the scenery packages folder. + public PackagesViewModel Packages { get; } + /// The client mesh downloads share, the setup's and the Packages page's. + public HttpClient MeshHttp { get; } = PackagesViewModel.CreateHttpClient(); + /// One region install or delete at a time, from the Packages page or the setup. + public InstallSlot MeshInstalls { get; } = new(); public LogViewModel LogPage { get; } public AboutViewModel About { get; } public IReadOnlyList Navigation { get; } diff --git a/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs new file mode 100644 index 0000000..f2f0ef3 --- /dev/null +++ b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs @@ -0,0 +1,240 @@ +using System.Collections.ObjectModel; +using System.Reflection; +using System.Windows.Input; +using OrthoStreamXP.Setup; + +namespace OrthoStreamXP.Gui; + +/// +/// The regions of AutoOrtho's scenery mesh, to download into the scenery packages folder: the Packages page, and the same +/// list in the setup's Scenery step for someone who has no packages yet. One install runs at a time; a region is updated +/// or deleted only while OrthoStreamXP is stopped (X-Plane has its files open while it runs). +/// +internal sealed class PackagesViewModel : ObservableObject, IPage +{ + private readonly MountController _mount; + private readonly WindowPreferences _preferences; + private readonly HttpClient _http; + private readonly Func _folder; + private readonly Action? _installed; + private readonly InstallSlot _slot; + private CancellationTokenSource? _install; + private bool _loading; + private string? _message; + + /// The scenery packages folder regions go into; null while there is none. + /// Called with the folder once a region is installed. + /// Shared with the window's other list of regions: one install or delete at a time. + public PackagesViewModel(MountController mount, WindowPreferences preferences, HttpClient http, InstallSlot slot, Func folder, + Action? installed = null) + { + _mount = mount; + _preferences = preferences; + _http = http; + _folder = folder; + _installed = installed; + _slot = slot; + Refresh = new RelayCommand(() => _ = LoadAsync(refresh: true), () => !Loading); + OpenFolder = new RelayCommand(() => Shell.Open(Folder!), () => Folder is { } f && Directory.Exists(f)); + OpenProject = new RelayCommand(() => Shell.Open(MeshCatalog.ProjectPage)); + _mount.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(MountController.State)) return; + Raise(nameof(RunningNote)); + RelayCommand.Refresh(); + }; + } + + public ObservableCollection Rows { get; } = []; + public ICommand Refresh { get; } + public ICommand OpenFolder { get; } + public ICommand OpenProject { get; } + + public string? Folder => _folder(); + + public string FolderText => Folder is { } folder + ? $"Regions are installed in your scenery packages folder, {folder}, beside your own packages. Your own packages stay above them in X-Plane, so where they overlap yours are shown." + : "Choose your scenery packages folder first (in the setup): the regions are installed there."; + + public bool DownloadOverlays + { + get => _preferences.DownloadOverlays; + set + { + if (_preferences.DownloadOverlays == value) return; + _preferences.DownloadOverlays = value; + _preferences.Save(); + Raise(); + } + } + + public bool Loading + { + get => _loading; + private set + { + Set(ref _loading, value); + RelayCommand.Refresh(); + } + } + + public string? Message + { + get => _message; + private set + { + Set(ref _message, value); + Raise(nameof(HasMessage)); + } + } + + public bool HasMessage => Message is not null; + + public string? RunningNote => _mount.IsStopped ? null : "OrthoStreamXP is running: a region installed now is used from its next start. Stop it to update or delete a region."; + + /// The folder regions go into may have changed (the setup's choices): what is installed there is read again. + public void FolderChanged() + { + Raise(nameof(FolderText)); + UpdateInstalled(); + RelayCommand.Refresh(); + } + + public void OnShown() + { + Raise(nameof(FolderText)); + _ = LoadAsync(refresh: false); + } + + /// The list from GitHub (or the one kept), and what is installed; rows of a running install are kept. + public async Task LoadAsync(bool refresh) + { + if (Loading) return; + Loading = true; + try + { + var catalog = new MeshCatalog(_http, Path.Join(AppPaths.Root, "mesh-regions.json")); + IReadOnlyList regions = await catalog.LoadAsync(refresh, CancellationToken.None).ConfigureAwait(true); + if (_install is null) + { + Rows.Clear(); + foreach (MeshRegion region in regions) Rows.Add(new RegionRow(this, region)); + } + Message = regions.Count == 0 ? "GitHub lists no mesh regions at the moment." : null; + UpdateInstalled(); + } + catch (IOException ex) + { + Message = $"{ex.Message} Check the internet connection, then press Refresh."; + } + finally + { + Loading = false; + } + } + + public bool CanInstall(RegionRow row) => + !_slot.Busy && Folder is not null && !row.UpToDate && (!row.IsInstalled || _mount.IsStopped); + + public bool CanDelete(RegionRow row) => !_slot.Busy && row.IsInstalled && !row.Deleting && _mount.IsStopped; + + public void StopInstall() => _install?.Cancel(); + + public async Task InstallAsync(RegionRow row) + { + if (!CanInstall(row) || Folder is not { } folder || !_slot.TryTake()) return; + using var cancel = new CancellationTokenSource(); + _install = cancel; + row.Error = null; + row.Busy = true; + Message = null; + RelayCommand.Refresh(); + try + { + var progress = new Progress(row.Report); + await Installer(folder).InstallAsync(row.Region, DownloadOverlays, progress, cancel.Token).ConfigureAwait(true); + _installed?.Invoke(folder); + } + catch (OperationCanceledException) + { + Log.Info($"The install of the {row.Name} mesh was stopped; what was downloaded is kept for the next time"); + } + catch (Exception ex) when (ex is InstallException or IOException or UnauthorizedAccessException) + { + row.Error = ex.Message; + Log.Warning(ex.Message); + } + catch (Exception ex) + { + // The install runs unawaited from a button: nothing that goes wrong may vanish. + row.Error = $"The {row.Name} mesh could not be installed: {ex.Message}"; + Log.Error(row.Error, ex); + } + finally + { + _install = null; + _slot.Release(); + RelayCommand.Refresh(); + row.Busy = false; + UpdateInstalled(); + } + } + + public async Task DeleteAsync(RegionRow row) + { + if (!CanDelete(row) || Folder is not { } folder || !_slot.TryTake()) return; + row.Deleting = true; + row.Error = null; + try + { + await Task.Run(() => Installer(folder).Delete(row.Region.Id)).ConfigureAwait(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + row.Error = $"The {row.Name} mesh could not be deleted: {ex.Message}"; + Log.Warning(row.Error); + } + catch (Exception ex) + { + row.Error = $"The {row.Name} mesh could not be deleted: {ex.Message}"; + Log.Error(row.Error, ex); + } + finally + { + row.Deleting = false; + _slot.Release(); + RelayCommand.Refresh(); + UpdateInstalled(); + } + } + + private RegionInstaller Installer(string folder) => new(new AssetDownload(_http), folder); + + private void UpdateInstalled() + { + if (Folder is not { } folder) + { + foreach (RegionRow row in Rows) (row.Installed, row.Partial) = (null, 0); + return; + } + RegionInstaller installer = Installer(folder); + IReadOnlyDictionary installed = installer.Installed(); + foreach (RegionRow row in Rows) + { + row.Installed = installed.GetValueOrDefault(row.Region.Id); + row.Partial = row.Busy || row.UpToDate ? 0 : installer.AlreadyDone(row.Region, DownloadOverlays); + } + } + + /// The client every mesh download shares: GitHub asks for a user agent, and a download may take hours. + public static HttpClient CreateHttpClient() + { + var http = new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(10) }) + { + Timeout = Timeout.InfiniteTimeSpan, // each download watches for silence itself + }; + string version = typeof(PackagesViewModel).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0"; + http.DefaultRequestHeaders.UserAgent.ParseAdd($"orthostreamxp/{version}"); + return http; + } +} diff --git a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs index 0f50dd0..cabd626 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs @@ -54,6 +54,8 @@ public SetupViewModel(MainViewModel main) _main = main; SettingsDocument existing = SettingsDocument.Load(SettingsDocument.DefaultPath); _winFsp = SystemCheck.WinFspInstalled(); + // Before the folders are chosen below: each choice tells it where regions would go. + Downloads = new PackagesViewModel(_main.Mount, _main.Preferences, _main.MeshHttp, _main.MeshInstalls, () => DownloadFolder, RegionInstalled); XPlaneOptions = [.. SystemCheck.FindXPlaneFolders().Select(f => new FolderOption(f, "X-Plane found here", ChooseXPlane))]; // Which packages to use is the person's own choice: nothing is searched for, only the folder chosen before is offered. @@ -106,6 +108,39 @@ public SetupViewModel(MainViewModel main) { if (Shell.PickFolder("Choose a folder for the cache", CacheFolder) is { } folder) CacheFolder = folder; }); + ShowDownloads = new RelayCommand(() => + { + DownloadsShown = true; + Downloads.OnShown(); + }, () => !DownloadsShown); + } + + /// The regions of the mesh, for someone without scenery packages: installed into the folder chosen, or a suggested one. + public PackagesViewModel Downloads { get; } + + public ICommand ShowDownloads { get; } + + public bool DownloadsShown + { + get => _downloadsShown; + private set + { + if (Set(ref _downloadsShown, value)) Raise(nameof(PackagesMissing)); + } + } + + private bool _downloadsShown; + + /// The packages folder chosen, when it can hold packages; otherwise the one the setup suggests. + private string DownloadFolder => + _packages is not null && XPlaneOk && !SystemCheck.IsInCustomScenery(_packages, _xplane) ? _packages : SystemCheck.SuggestedPackagesFolder(); + + /// A region is installed: its folder becomes the packages folder, its card telling the packages now in it. + private void RegionInstalled(string folder) + { + FolderOption? old = PackageOptions.FirstOrDefault(o => string.Equals(o.Path, folder, StringComparison.OrdinalIgnoreCase)); + if (old is not null) PackageOptions.Remove(old); + Select(PackageOptions, folder, ChoosePackages, null); } public ObservableCollection XPlaneOptions { get; } @@ -160,6 +195,13 @@ public int Step /// A folder was chosen and it has no packages (shown in red; before a choice the hint is just a hint). public bool PackagesWrong => _packages is not null && !PackagesOk; + /// + /// The folder chosen can be used but holds no packages: the download is offered right under the message, until the + /// list of regions is open. + /// + public bool PackagesMissing => + !DownloadsShown && _packages is not null && XPlaneOk && !SystemCheck.IsInCustomScenery(_packages, _xplane) && SystemCheck.PackagesIn(_packages).Count == 0; + public string PackagesHint { get @@ -171,7 +213,9 @@ public string PackagesHint if (!XPlaneOk) return SystemCheck.XPlaneFirstExplanation; if (SystemCheck.IsInCustomScenery(_packages, _xplane)) return SystemCheck.InCustomSceneryExplanation; IReadOnlyList found = SystemCheck.PackagesIn(_packages); - if (found.Count == 0) return "That folder has no scenery packages (folders with an Earth nav data folder in them)."; + if (found.Count == 0) + return "That folder has no scenery packages: none of its folders has an Earth nav data folder. " + + "Choose the folder that holds your packages, not a package itself. No packages yet? Download a region below."; IReadOnlyList taken = SystemCheck.TakenInCustomScenery(_xplane, found); return taken.Count == 0 ? "OrthoStreamXP adds photos to these packages while it runs. They stay unchanged on disk." @@ -265,7 +309,9 @@ private void ChooseXPlane(FolderOption option) Raise(nameof(PackagesOk)); Raise(nameof(PackagesWrong)); Raise(nameof(PackagesHint)); + Raise(nameof(PackagesMissing)); Raise(nameof(Summary)); + Downloads.FolderChanged(); RelayCommand.Refresh(); } @@ -277,7 +323,9 @@ private void ChoosePackages(FolderOption option) Raise(nameof(PackagesOk)); Raise(nameof(PackagesWrong)); Raise(nameof(PackagesHint)); + Raise(nameof(PackagesMissing)); Raise(nameof(Summary)); + Downloads.FolderChanged(); RelayCommand.Refresh(); } diff --git a/src/OrthoStreamXP/Gui/Views/PackagesView.xaml b/src/OrthoStreamXP/Gui/Views/PackagesView.xaml new file mode 100644 index 0000000..1f86d5c --- /dev/null +++ b/src/OrthoStreamXP/Gui/Views/PackagesView.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + +