diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7eb86d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,28 @@ +name: Bug report +description: Report a loading, alignment, geometry or interface problem. +body: + - type: textarea + id: environment + attributes: + label: Versions and component + description: RhinoSpatial version, Rhino version, operating system and affected component. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps and source + description: How can we reproduce this? Include a public source URL or a small non-private file, study area and SRS where possible. Do not share API keys or private project data. + validations: + required: true + - type: textarea + id: behavior + attributes: + label: Expected and actual behavior + validations: + required: true + - type: textarea + id: status + attributes: + label: Status output and screenshots + description: Paste the component's full Status message and relevant screenshots, with secrets removed. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..52a840f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: User guide and examples + url: https://pascalnun.eu/tools/rhinospatial/ + about: Installation and the shared Spatial Context workflow. + - name: Component and source reference + url: https://github.com/PascalNun/RhinoSpatial/blob/main/docs/COMPONENT_REFERENCE.md + about: Supported formats, inputs, coordinate behavior and diagnostic messages. diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml new file mode 100644 index 0000000..ee9dd12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -0,0 +1,17 @@ +name: Improvement or provider compatibility +description: Suggest a practical improvement to an existing RhinoSpatial workflow. +body: + - type: textarea + id: workflow + attributes: + label: Workflow and problem + description: What are you trying to do, and what is difficult or unsupported today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Explain the useful outcome. Include a public provider URL or sample when relevant. + validations: + required: true diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..4d2fec8 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,9 @@ +# Help and support + +- Start with the [website and user guide](https://pascalnun.eu/tools/rhinospatial/). +- See the [component reference](../docs/COMPONENT_REFERENCE.md) for formats, inputs, coordinate behavior and diagnostics. +- Try the [Grasshopper examples](../examples/README.md). +- Download the recommended version from [GitHub Latest](https://github.com/PascalNun/RhinoSpatial/releases/latest), [Food4Rhino](https://www.food4rhino.com/en/app/rhinospatial) or Rhino 8's Package Manager. +- Report reproducible bugs or propose improvements through [GitHub Issues](https://github.com/PascalNun/RhinoSpatial/issues/new/choose). + +Include the component's full Status output and a small public test source where possible. Remove API keys, passwords and private data before sharing a definition or screenshot. Provider-specific service changes and unusual file metadata may require a compatibility fix. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..730536e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## What changes + +Describe the problem, trigger and resulting behavior. + +## Validation + +List automated checks and any relevant Rhino/Grasshopper checks actually performed. State remaining manual validation or provider limitations. + +## Compatibility + +Note effects on component identities, input/output layouts, saved definitions, shared alignment, dependencies and documentation. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9b1574a..2a60b61 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,7 +11,7 @@ permissions: jobs: build-and-check: - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 20 steps: @@ -37,6 +37,15 @@ jobs: - name: Check reference sources run: dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- reference-source-check + - name: Check source readers + run: dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- source-reader-check + + - name: Check terrain meshes + run: dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- terrain-mesh-check + + - name: Check ATOM downloads and cache + run: dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- lod2-atom-check + - name: Check 3D Tiles selection run: dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- tiles-selection-check diff --git a/CHANGELOG.md b/CHANGELOG.md index cda4e3c..3f257ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,38 @@ # Changelog -RhinoSpatial follows an alpha release line while its existing source categories are stabilized through real project use. Earlier releases remain documented on [GitHub Releases](https://github.com/PascalNun/RhinoSpatial/releases). +RhinoSpatial improves its established source categories through real project use. Earlier releases remain documented on [GitHub Releases](https://github.com/PascalNun/RhinoSpatial/releases). -## Unreleased +## 0.4.0 — 2026-11-01 + +Version 0.4.0 is the recommended public release and is published as GitHub Latest. Component names, GUIDs and normal input/output connections are retained. Provider-specific compatibility continues to evolve. + +### Automatic LoD2 ATOM downloads + +- Added ATOM download services to the existing `Load LoD2 Buildings` source input, preserving the component GUID and input/output layout. +- Follow dataset feeds and pagination, select intersecting tiles from supported spatial metadata or declared UTM kilometre grids, and reuse the existing CityGML ZIP import and shared placement path. +- Added persistent validated archive caching, download progress, cancellation/time limits and explicit diagnostics for missing spatial information, failed transfers and unsupported archives. +- Queue progress updates asynchronously to prevent a Rhino UI/Grasshopper worker deadlock during ATOM loading on macOS. +- Diagnose explicitly advertised CSV/raster downloads as unsuitable for the building loader before evaluating their tile grid, including Berlin's DGM1 terrain feed. +- Recognize standard AdV UTM32/UTM33 horizontal CRS names, including Berlin's compound height-reference identifier, while retaining source heights. +- Added deterministic ATOM regression checks and a live Berlin probe. Museum Island and a two-tile boundary area were exercised through download, cache reuse and filtered CityGML parsing; interactive loading in Rhino on macOS was confirmed after correcting the progress deadlock; detailed cross-layer alignment remains on the manual checklist. + +### Terrain and vector correctness + +- Corrected Esri ASCII Grid cell-center placement and reference extents, optional NoData headers, and wrapped cell values; malformed dimensions and incomplete or excess data now return clear errors. +- Reject irregular XYZ/CSV terrain spacing and non-finite positions instead of distorting their placement. +- Transform terrain from every supported source CRS into the shared context, preserve downsampled raster edges, and leave NoData areas open instead of creating artificial flat surfaces. Unsupported WCS coordinate systems and non-overlapping coverage fail explicitly. +- Prevent missing terrain elevations from being reused as invalid building-height samples. +- Accept GeoJSON null attributes and empty geometries without fabricating origin points; correctly read versioned EPSG URN and URL identifiers. +- Added deterministic source-reader and terrain-mesh regression checks. Rhino/Grasshopper visual validation is still required for the changed terrain output. ### Documentation and package presentation - Reorganized the README around the architect and designer workflow, with a shorter introduction, clearer installation path, and progressive links into technical detail. - Added a complete [Component and Source Reference](docs/COMPONENT_REFERENCE.md) so supported formats, inputs, placement behavior, fallbacks, and diagnostics remain available without overwhelming the first page. -- Refreshed the project scope, roadmap, workflow guide, examples, validation notes, and source catalogue to match the current `0.3.3-alpha` behavior and terminology. +- Refreshed the project scope, roadmap, workflow guide, examples, validation notes, and source catalogue to match the established toolkit scope and current terminology. - Added a self-contained release-package README and corrected package instructions, links, author name, and user-facing metadata. +- Updated version and installation guidance for 0.4.0; the managed Yak package now targets Rhino 8 on macOS and Windows. +- Added contributor/support guidance, issue forms and a pull request template, and included all six regression suites in GitHub validation. ## 0.3.3-alpha — 2026-07-18 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3e14fc6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to RhinoSpatial + +RhinoSpatial brings multiple geospatial layers into one aligned Rhino and Grasshopper study area. Contributions should strengthen that workflow through reproducible compatibility fixes, correctness, clear diagnostics, documentation and practical examples. + +## Report a problem + +Open a [bug report](https://github.com/PascalNun/RhinoSpatial/issues/new/choose). Include the RhinoSpatial version, Rhino version, operating system, component, selected area/SRS, exact status message and steps to reproduce. A small public source URL or non-private sample is especially helpful. Never publish API keys, authentication links or private project data. + +## Propose a change + +Check the [project scope](docs/PROJECT_SCOPE.md) and [roadmap](docs/ROADMAP.md). For substantial changes, describe the real workflow and proposed behavior in an issue first. Keep pull requests focused and preserve component names, GUIDs, input/output layouts and existing Grasshopper definitions. + +Reusable source readers, clients and coordinate logic belong in `RhinoSpatial.Core`; Grasshopper orchestration and Rhino geometry belong in `RhinoSpatial`. All aligned sources use the shared Spatial Context. Unsupported coordinates, missing coverage and source failures should produce honest diagnostics. + +## Build and validate + +Install a .NET SDK able to build the solution's .NET 7 and .NET 10 targets, then run: + +```bash +dotnet build RhinoSpatial.sln -c Release +dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- ogc-request-check +dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- reference-source-check +dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- tiles-selection-check +dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- source-reader-check +dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- terrain-mesh-check +dotnet run --project RhinoSpatial.Sandbox.csproj -c Release --no-build -- lod2-atom-check +git diff --check +``` + +Update the relevant documentation and [test-source catalogue](docs/TEST_SOURCES.md). Changes to Rhino geometry, preview, materials, UI or saved definitions also need the relevant [manual checks](examples/VALIDATION.md); console checks do not establish visual correctness. Explain the validation performed and its limits in the pull request. + +RhinoSpatial uses the [MIT License](LICENSE). Keep third-party notices current when dependencies change. diff --git a/README.md b/README.md index 1da3274..212a62b 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,19 @@ The easiest installation path is Rhino 8's Package Manager: 1. Open Rhino 8. 2. Run `PackageManager`. -3. Enable **Include pre-releases** while RhinoSpatial remains in alpha. -4. Search for `RhinoSpatial`. -5. Install the package and restart Rhino or Grasshopper if requested. +3. Search for `RhinoSpatial`. +4. Install the package and restart Rhino or Grasshopper if requested. You can also find RhinoSpatial on [Food4Rhino](https://www.food4rhino.com/en/app/rhinospatial?lang=en). -For manual installation, open [GitHub Releases](https://github.com/PascalNun/RhinoSpatial/releases), choose the current release, and download its `RhinoSpatial-…-alpha.zip` asset. See the [manual installation guide](package/food4rhino/INSTALL.md) for the Grasshopper Libraries locations on macOS and Windows. +For manual installation, open [GitHub Releases](https://github.com/PascalNun/RhinoSpatial/releases), choose the current release, and download its `RhinoSpatial-0.4.0.zip` asset. See the [manual installation guide](package/food4rhino/INSTALL.md) for the Grasshopper Libraries locations on macOS and Windows. Release packages include a small number of third-party libraries for raster handling, topology operations, coordinate transforms, and 3D tile decoding. Their licenses are documented in [Third-Party Notices](THIRD-PARTY-NOTICES.md). +## New in 0.4.0 + +Connect a supported ATOM download service directly to `Load LoD2 Buildings`: RhinoSpatial selects the required CityGML tiles for your study area and reuses completed downloads. This release also adds AdV UTM CRS names and corrects terrain placement, NoData handling and local vector parsing. See the [changelog](CHANGELOG.md) for details. + ## Your first RhinoSpatial workflow 1. Open Grasshopper and place `Spatial Context`. @@ -77,7 +80,7 @@ For a visual walkthrough, open the [RhinoSpatial workflow guide](docs/SHOWCASE.m | Maps, aerial imagery, orthophotos, or raster overlays | `Load WMS` | WMS imagery placed as an aligned image mesh and material | | Local georeferenced imagery | `Load GeoTIFF` | A local `.tif` or `.tiff` placed in the same study space | | Ground and elevation context | `Load Terrain` | WCS, GeoTIFF DEM, Esri ASCII Grid, XYZ/CSV grid, or a small-area fallback; aligned terrain meshes | -| Official 3D building and roof geometry | `Load LoD2 Buildings` | WFS, CityGML/GML/XML, CityJSON, folder, or ZIP archive; building Breps | +| Official 3D building and roof geometry | `Load LoD2 Buildings` | WFS, ATOM download feeds, CityGML/GML/XML, CityJSON, folder, or ZIP archive; building Breps | | Fast urban and landscape context | `Load OSM` | OpenStreetMap buildings, roads, water, green areas, and rail | | Photogrammetric visual reference | `3D Tiles Viewer (Google)` | Temporary preview meshes and materials using your own Google Maps API key | @@ -89,9 +92,9 @@ See the [Component and Source Reference](docs/COMPONENT_REFERENCE.md) for suppor ## Current status -RhinoSpatial is in active alpha development. The current `v0.3.3-alpha` line contains the complete planned source set: Spatial Context, vector data, imagery, terrain, LoD2 buildings, GeoTIFF, OSM, and the optional Google 3D Tiles reference viewer. +**0.4.0 is the recommended public release**, available as GitHub Latest and through Rhino’s Package Manager without enabling pre-releases. RhinoSpatial contains the complete planned source set: Spatial Context, vector data, imagery, terrain, LoD2 buildings, GeoTIFF, OSM, and the optional Google 3D Tiles reference viewer. -The toolkit has been exercised against a growing international set of public services and local formats. Individual providers, coordinate systems, geometry types, service versions, API access rules, and file metadata can still expose edge cases. For project work, prefer official or project-specific sources and keep an older working definition when testing a new alpha release. +The toolkit has been exercised against a growing international set of public services and local formats. Individual providers, coordinate systems, geometry types, service versions, API access rules, and file metadata can still expose edge cases. For project work, prefer official or project-specific sources and keep an older working definition when upgrading. The [changelog](CHANGELOG.md) records release changes. The [test source catalogue](docs/TEST_SOURCES.md) documents the current compatibility set and known provider limitations. diff --git a/RhinoSpatial.Core/GeoJsonReader.cs b/RhinoSpatial.Core/GeoJsonReader.cs index 51a7a79..a091dcc 100644 --- a/RhinoSpatial.Core/GeoJsonReader.cs +++ b/RhinoSpatial.Core/GeoJsonReader.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; namespace RhinoSpatial.Core @@ -34,22 +35,12 @@ public static class GeoJsonReader var epsgIndex = name.LastIndexOf("EPSG", StringComparison.OrdinalIgnoreCase); if (epsgIndex >= 0) { - var digits = string.Empty; - for (var index = epsgIndex + 4; index < name.Length; index++) + var code = name[(epsgIndex + 4)..].Trim() + .Split(new[] { ':', '/', '#' }, StringSplitOptions.RemoveEmptyEntries) + .LastOrDefault(); + if (!string.IsNullOrEmpty(code) && code.All(char.IsAsciiDigit)) { - if (char.IsDigit(name[index])) - { - digits += name[index]; - } - else if (digits.Length > 0) - { - break; - } - } - - if (!string.IsNullOrWhiteSpace(digits)) - { - return $"EPSG:{digits}"; + return $"EPSG:{code}"; } } @@ -100,6 +91,10 @@ private static WfsFeature ReadFeature(JsonElement featureElement, string sourceL private static Dictionary ReadAttributes(JsonElement propertiesElement) { var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (propertiesElement.ValueKind == JsonValueKind.Null) + { + return attributes; + } foreach (var property in propertiesElement.EnumerateObject()) { @@ -128,6 +123,10 @@ private static WfsGeometry ReadGeometry(JsonElement geometryElement) } var geometryType = geometryElement.GetProperty("type").GetString() ?? "Unknown"; + if (coordinatesElement.ValueKind == JsonValueKind.Array && coordinatesElement.GetArrayLength() == 0) + { + return new WfsGeometry { Type = geometryType }; + } return geometryType switch { @@ -248,7 +247,7 @@ private static Coordinate2D ReadPoint(JsonElement pointElement) { if (pointElement.GetArrayLength() < 2) { - return new Coordinate2D(0, 0); + throw new InvalidOperationException("A GeoJSON point must contain at least two coordinates."); } return new Coordinate2D(pointElement[0].GetDouble(), pointElement[1].GetDouble()); diff --git a/RhinoSpatial.Core/Lod2AtomClient.cs b/RhinoSpatial.Core/Lod2AtomClient.cs new file mode 100644 index 0000000..e8bd3c4 --- /dev/null +++ b/RhinoSpatial.Core/Lod2AtomClient.cs @@ -0,0 +1,296 @@ +using System.Collections.Concurrent; +using System.IO.Compression; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; + +namespace RhinoSpatial.Core; + +public record Lod2AtomSelection(IReadOnlyList Tiles, int FeedCount, int DownloadCount); +public record Lod2AtomCachedTile(Lod2AtomLink Tile, string FilePath, bool UsedCache); + +public sealed class Lod2AtomClient +{ + private static readonly HttpClient SharedHttpClient = new() { Timeout = Timeout.InfiniteTimeSpan }; + private static readonly ConcurrentDictionary FeedCache = new(StringComparer.Ordinal); + public static string DefaultCacheDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RhinoSpatial", "Lod2AtomCache"); + private readonly HttpClient _http; + private readonly string _cacheDirectory; + private const long MaximumDownloadBytes = 512L * 1024 * 1024; + private const long MaximumExpandedBytes = 1024L * 1024 * 1024; + + public Lod2AtomClient(HttpClient? httpClient = null, string? cacheDirectory = null) + { + _http = httpClient ?? SharedHttpClient; + _cacheDirectory = cacheDirectory ?? DefaultCacheDirectory; + } + + public async Task TrySelectAsync(string source, SpatialContext2D context, CancellationToken cancellationToken = default) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(2)); + try { return await TrySelectCoreAsync(source, context, timeout.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { throw new TimeoutException("ATOM tile discovery timed out after two minutes. Select a more specific dataset feed or try again later."); } + } + + private async Task TrySelectCoreAsync(string source, SpatialContext2D context, CancellationToken cancellationToken) + { + var initial = await LoadFeedAsync(new Uri(source), allowNonAtom: true, cancellationToken).ConfigureAwait(false); + if (initial is null) + return null; + var queue = new Queue<(XDocument Document, Uri Url, AtomSpatialExtent? Extent, int Depth)>(); + queue.Enqueue((initial.Value.Document, initial.Value.Url, null, 0)); + var visited = new HashSet(StringComparer.Ordinal) { initial.Value.Url.AbsoluteUri }; + var tiles = new Dictionary(StringComparer.Ordinal); + var downloadCount = 0; + var feedCount = 0; + var missing = new List(); + var contextExtent = new AtomSpatialExtent(context.ResolvedSrs, context.RequestBoundingBox); + var contextBounds = new Dictionary(StringComparer.OrdinalIgnoreCase); + bool Intersects(AtomSpatialExtent extent) + { + if (!contextBounds.TryGetValue(extent.SrsName, out var bounds)) + { + bounds = Lod2AtomFeedReader.TransformBounds(contextExtent, extent.SrsName); + contextBounds[extent.SrsName] = bounds; + } + return Lod2AtomFeedReader.Intersects(extent.Bounds, bounds); + } + + while (queue.TryDequeue(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++feedCount > 64 || current.Depth > 8) + throw new InvalidOperationException("ATOM feed traversal limit reached. Select a more specific dataset feed."); + var feed = Lod2AtomFeedReader.Read(current.Document, current.Url, current.Extent); + downloadCount += feed.Downloads.Count; + if (downloadCount > 50000) + throw new InvalidOperationException("The ATOM catalogue exceeds the supported download-entry limit."); + foreach (var tile in feed.Downloads) + { + if (tile.Extent is null) + { + if (missing.Count < 5) missing.Add(tile.Title); + continue; + } + if (!Intersects(tile.Extent)) continue; + tiles.TryAdd(tile.Url.AbsoluteUri, tile); + } + foreach (var child in feed.Feeds) + { + if (child.Extent is not null && !Intersects(child.Extent)) continue; + if (!visited.Add(child.Url.AbsoluteUri)) continue; + if (visited.Count > 64 || current.Depth >= 8) + throw new InvalidOperationException("ATOM feed traversal limit reached. Select a more specific dataset feed."); + var loaded = await LoadFeedAsync(child.Url, allowNonAtom: false, cancellationToken).ConfigureAwait(false); + visited.Add(loaded!.Value.Url.AbsoluteUri); + queue.Enqueue((loaded.Value.Document, loaded.Value.Url, child.Extent, current.Depth + 1)); + } + } + if (missing.Count > 0) + throw new InvalidOperationException($"The ATOM feed does not provide sufficient per-tile spatial metadata to select downloads safely. Example entries: {string.Join(", ", missing)}. No building archives were downloaded. Use a spatially indexed feed or local tiles."); + if (tiles.Count > 32) + throw new InvalidOperationException($"The area selects {tiles.Count} ATOM tiles; the limit is 32 per solution. Select a smaller study area."); + return new Lod2AtomSelection(tiles.Values.OrderBy(tile => tile.Url.AbsoluteUri, StringComparer.Ordinal).ToList(), feedCount, downloadCount); + } + + private async Task<(XDocument Document, Uri Url)?> LoadFeedAsync(Uri url, bool allowNonAtom, CancellationToken cancellationToken) + { + if (ReferenceEquals(_http, SharedHttpClient) && FeedCache.TryGetValue(url.AbsoluteUri, out var cached) && cached.Expires > DateTime.UtcNow) + return (cached.Document, cached.Url); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + var actualUrl = response.RequestMessage?.RequestUri ?? url; + var declaredAtom = response.Content.Headers.ContentType?.MediaType?.Equals("application/atom+xml", StringComparison.OrdinalIgnoreCase) == true; + if (!response.IsSuccessStatusCode && allowNonAtom && !declaredAtom && + !url.AbsolutePath.TrimEnd('/').EndsWith("atom", StringComparison.OrdinalIgnoreCase)) + return null; // Some WFS roots require GetCapabilities query parameters. + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); + using var reader = XmlReader.Create(stream, new XmlReaderSettings + { + Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, + MaxCharactersInDocument = 16 * 1024 * 1024, IgnoreComments = true + }); + try + { + await reader.MoveToContentAsync().ConfigureAwait(false); + if (reader.LocalName != "feed" || reader.NamespaceURI != Lod2AtomFeedReader.Atom.NamespaceName) + { + if (allowNonAtom && !declaredAtom) return null; + throw new InvalidOperationException($"Expected an ATOM feed at '{url}'."); + } + } + catch (XmlException) when (allowNonAtom && !declaredAtom) + { + return null; + } + var document = await XDocument.LoadAsync(reader, LoadOptions.None, timeout.Token).ConfigureAwait(false); + if (ReferenceEquals(_http, SharedHttpClient)) + { + if (FeedCache.Count > 128) FeedCache.Clear(); + FeedCache[url.AbsoluteUri] = (DateTime.UtcNow.AddMinutes(5), document, actualUrl); + } + return (document, actualUrl); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Timed out reading the ATOM feed '{url}'. Try again when the service is available."); + } + } + + public async Task> DownloadAsync(Lod2AtomSelection selection, + Action? progress = null, CancellationToken cancellationToken = default) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(5)); + try { return await DownloadCoreAsync(selection, progress, timeout.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { throw new TimeoutException("ATOM downloads exceeded five minutes. Completed tiles remain cached; reduce the study area or retry."); } + } + + private async Task> DownloadCoreAsync(Lod2AtomSelection selection, + Action? progress, CancellationToken cancellationToken) + { + var result = new List(); + foreach (var tile in selection.Tiles) + { + cancellationToken.ThrowIfCancellationRequested(); + progress?.Invoke($"ATOM tile {result.Count + 1}/{selection.Tiles.Count}: {Path.GetFileName(tile.Url.LocalPath)}"); + try + { + var prefix = $"ATOM {result.Count + 1}/{selection.Tiles.Count}"; + var cachedTile = await DownloadTileAsync(tile, note => progress?.Invoke($"{prefix}: {note}"), cancellationToken).ConfigureAwait(false); + result.Add(cachedTile); + progress?.Invoke($"{prefix}: {(cachedTile.UsedCache ? "reused cache" : "download complete")}"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"Could not load ATOM tile '{tile.Title}': {ex.Message} Completed {result.Count}/{selection.Tiles.Count} tile(s); successful downloads remain cached. No partial building result was returned.", ex); + } + } + return result; + } + + private sealed record CacheStamp(long Length, string Hash, DateTime DownloadedUtc); + + private async Task DownloadTileAsync(Lod2AtomLink tile, Action progress, CancellationToken cancellationToken) + { + Directory.CreateDirectory(_cacheDirectory); + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tile.Url.AbsoluteUri + "\n" + tile.Revision))); + var target = Path.Combine(_cacheDirectory, key + ".zip"); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(3)); + var temporary = target + "." + Guid.NewGuid().ToString("N") + ".partial"; + try + { + // A filesystem lock also serializes writers from separate Rhino processes. + await using var cacheLock = await AcquireLockAsync(target + ".lock", timeout.Token).ConfigureAwait(false); + if (await IsValidCacheAsync(target, tile.Revision, timeout.Token).ConfigureAwait(false)) + return new Lod2AtomCachedTile(tile, target, true); + using var response = await _http.GetAsync(tile.Url, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var expectedLength = response.Content.Headers.ContentLength; + if (expectedLength > MaximumDownloadBytes) + throw new InvalidOperationException("Archive exceeds the 512 MiB download limit."); + await using (var input = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false)) + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)) + { + var buffer = new byte[81920]; + long count = 0; + long lastProgressBytes = 0; + int read; + while ((read = await input.ReadAsync(buffer, timeout.Token).ConfigureAwait(false)) != 0) + { + count += read; + if (count > MaximumDownloadBytes) throw new InvalidOperationException("Archive exceeds the 512 MiB download limit."); + await output.WriteAsync(buffer.AsMemory(0, read), timeout.Token).ConfigureAwait(false); + if (count - lastProgressBytes >= 1024 * 1024) + { + progress($"downloaded {count / (1024 * 1024)} MiB" + (expectedLength.HasValue ? $" / {expectedLength.Value / (1024 * 1024)} MiB" : "")); + lastProgressBytes = count; + } + } + if (expectedLength.HasValue && count != expectedLength.Value) + throw new InvalidDataException("The archive download was incomplete."); + } + await ValidateArchiveAsync(temporary, timeout.Token).ConfigureAwait(false); + await using var validated = File.OpenRead(temporary); + var stamp = new CacheStamp(validated.Length, Convert.ToHexString(await SHA256.HashDataAsync(validated, timeout.Token).ConfigureAwait(false)), DateTime.UtcNow); + validated.Close(); + File.Move(temporary, target, overwrite: true); + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(stamp), timeout.Token).ConfigureAwait(false); + File.Move(temporary, target + ".json", overwrite: true); + return new Lod2AtomCachedTile(tile, target, false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("The ATOM archive download timed out after three minutes."); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static async Task AcquireLockAsync(string path, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException) { await Task.Delay(150, cancellationToken).ConfigureAwait(false); } + } + } + + private static async Task IsValidCacheAsync(string path, string revision, CancellationToken cancellationToken) + { + try + { + if (!File.Exists(path) || !File.Exists(path + ".json")) return false; + var stamp = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path + ".json", cancellationToken).ConfigureAwait(false)); + if (stamp is null || new FileInfo(path).Length != stamp.Length || + (string.IsNullOrWhiteSpace(revision) && DateTime.UtcNow - stamp.DownloadedUtc > TimeSpan.FromDays(1))) return false; + await using var stream = File.OpenRead(path); + return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)) == stamp.Hash; + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException) { return false; } + } + + private static async Task ValidateArchiveAsync(string path, CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead(path); + if (archive.Entries.Count > 10000) throw new InvalidDataException("ZIP contains too many entries."); + long expandedBytes = 0; + var buildingFiles = 0; + foreach (var entry in archive.Entries) + { + if (entry.Length > 256L * 1024 * 1024 || (expandedBytes += entry.Length) > MaximumExpandedBytes) + throw new InvalidDataException("ZIP exceeds the supported expanded-size limit (256 MiB per file, 1 GiB total)."); + var extension = Path.GetExtension(entry.FullName).ToLowerInvariant(); + if (extension is not (".gml" or ".citygml" or ".xml")) continue; + await using var stream = entry.Open(); + using var reader = XmlReader.Create(stream, new XmlReaderSettings + { + Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 256L * 1024 * 1024 + }); + await reader.MoveToContentAsync().ConfigureAwait(false); + if (reader.LocalName == "CityModel" && reader.NamespaceURI.StartsWith("http://www.opengis.net/citygml", StringComparison.Ordinal)) + { + buildingFiles++; + // Verify XML and decompression through EOF before publishing a completed cache file. + while (await reader.ReadAsync().ConfigureAwait(false)) cancellationToken.ThrowIfCancellationRequested(); + } + } + if (buildingFiles == 0) + throw new InvalidDataException("Unsupported or invalid archive: expected a ZIP containing CityGML building data. Other archive formats and remote CityJSON downloads are not supported."); + } +} diff --git a/RhinoSpatial.Core/Lod2AtomFeedReader.cs b/RhinoSpatial.Core/Lod2AtomFeedReader.cs new file mode 100644 index 0000000..126aa87 --- /dev/null +++ b/RhinoSpatial.Core/Lod2AtomFeedReader.cs @@ -0,0 +1,229 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace RhinoSpatial.Core; + +public record AtomSpatialExtent(string SrsName, BoundingBox2D Bounds); +public record Lod2AtomLink(Uri Url, string Title, string Revision, AtomSpatialExtent? Extent); +public record Lod2AtomFeed(IReadOnlyList Feeds, IReadOnlyList Downloads); + +/// Reads download metadata only; never opens building archives to locate tiles. +public static class Lod2AtomFeedReader +{ + public static readonly XNamespace Atom = "http://www.w3.org/2005/Atom"; + private static readonly XNamespace GeoRss = "http://www.georss.org/georss"; + + public static Lod2AtomFeed Read(XDocument document, Uri source, AtomSpatialExtent? inheritedExtent = null) + { + var root = document.Root; + if (root?.Name != Atom + "feed") + throw new InvalidOperationException("The source is not an ATOM feed."); + + var feeds = new List(); + var downloads = new List(); + var feedExtent = ReadExtent(root) ?? inheritedExtent; + var feedSrs = ReadCategorySrs(root); + AddFeedLinks(root, feedExtent); + + foreach (var entry in root.Elements(Atom + "entry")) + { + var extent = ReadExtent(entry); + var coverage = extent ?? feedExtent; + AddFeedLinks(entry, coverage); + var links = entry.Elements(Atom + "link").Where(IsDownloadLink).ToList(); + var srs = ReadCategorySrs(entry) ?? feedSrs; + var gridSize = ReadGridSize(entry) ?? ReadGridSize(root); + foreach (var link in links) + { + var contentType = ((string?)link.Attribute("type") ?? "").Split(';')[0].Trim(); + if (contentType.Equals("text/csv", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"This ATOM download advertises '{contentType}', not CityGML building data. Load LoD2 Buildings requires a CityGML download feed. Terrain ATOM feeds are not supported; use local terrain files or a WCS source with Load Terrain."); + var url = ResolveUrl(link, source); + var tileExtent = ReadExtent(link); + if (tileExtent is null && links.Count == 1) + tileExtent = extent; + if (tileExtent is null && gridSize.HasValue && srs is not null) + tileExtent = ReadDeclaredGridTile(url, srs, gridSize.Value, coverage); + downloads.Add(CreateLink(link, entry, url, tileExtent)); + } + } + + return new Lod2AtomFeed(feeds, downloads); + + void AddFeedLinks(XElement owner, AtomSpatialExtent? coverage) + { + foreach (var link in owner.Elements(Atom + "link")) + { + var relation = (string?)link.Attribute("rel") ?? "alternate"; + var type = (string?)link.Attribute("type") ?? ""; + var href = (string?)link.Attribute("href") ?? ""; + if (relation is "self" or "up" or "describedby" or "previous" or "prev") + continue; + if (relation != "next" && !type.StartsWith("application/atom+xml", StringComparison.OrdinalIgnoreCase) && + !href.Split('?', '#')[0].EndsWith(".atom", StringComparison.OrdinalIgnoreCase)) + continue; + // A pagination link describes another page of this feed, not a tile. + feeds.Add(CreateLink(link, owner, ResolveUrl(link, source), relation == "next" ? feedExtent : coverage)); + } + } + } + + private static Lod2AtomLink CreateLink(XElement link, XElement owner, Uri url, AtomSpatialExtent? extent) => + new(url, (string?)link.Attribute("title") ?? (string?)owner.Element(Atom + "title") ?? Path.GetFileName(url.LocalPath), + (string?)owner.Element(Atom + "updated") ?? (string?)owner.Parent?.Element(Atom + "updated") ?? "", extent); + + private static bool IsDownloadLink(XElement link) + { + var relation = (string?)link.Attribute("rel") ?? "alternate"; + if (relation is not ("enclosure" or "section" or "alternate")) + return false; + var type = ((string?)link.Attribute("type") ?? "").ToLowerInvariant(); + var href = ((string?)link.Attribute("href") ?? "").Split('?', '#')[0]; + return (relation == "enclosure" && !type.Contains("atom+xml")) || type.Contains("zip") || type.Contains("gmz") || type.Contains("7z") || + new[] { ".zip", ".gmz", ".7z", ".gz", ".tar", ".rar" }.Any(extension => href.EndsWith(extension, StringComparison.OrdinalIgnoreCase)); + } + + public static Uri ResolveUrl(XElement link, Uri source) + { + var baseUri = source; + foreach (var element in link.AncestorsAndSelf().Reverse()) + { + if (element.Attribute(XNamespace.Xml + "base") is { } xmlBase) + baseUri = new Uri(baseUri, xmlBase.Value); + } + var uri = new Uri(baseUri, (string?)link.Attribute("href") ?? throw new InvalidOperationException("ATOM link has no URL.")); + if (uri.Scheme is not ("http" or "https")) + throw new InvalidOperationException("ATOM downloads and subfeeds must use HTTP or HTTPS URLs."); + return new UriBuilder(uri) { Fragment = "" }.Uri; + } + + private static string? ReadCategorySrs(XElement owner) + { + var codes = owner.Elements(Atom + "category") + .Select(category => Regex.Match((string?)category.Attribute("term") ?? "", @"EPSG(?::|/)(?:[^:/]*[:/])?(\d+)$", RegexOptions.IgnoreCase)) + .Where(match => match.Success).Select(match => "EPSG:" + match.Groups[1].Value).Distinct().ToList(); + return codes.Count == 1 ? codes[0] : null; + } + + private static double? ReadGridSize(XElement owner) + { + // Only an explicit square-grid declaration enables kilometre-coordinate filenames. + var sizes = owner.Elements(Atom + "link").Select(link => Regex.Match( + (string?)link.Attribute("title") ?? "", @"\b(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)\s*(km|m)\b", RegexOptions.IgnoreCase)) + .Where(match => match.Success).Select(match => ( + X: double.Parse(match.Groups[1].Value.Replace(',', '.'), CultureInfo.InvariantCulture), + Y: double.Parse(match.Groups[2].Value.Replace(',', '.'), CultureInfo.InvariantCulture), + Scale: match.Groups[3].Value.Equals("km", StringComparison.OrdinalIgnoreCase) ? 1000 : 1)) + .Where(size => size.X == size.Y && size.X > 0).Select(size => size.X * size.Scale).Distinct().ToList(); + return sizes.Count == 1 ? sizes[0] : null; + } + + private static AtomSpatialExtent? ReadDeclaredGridTile(Uri url, string srs, double size, AtomSpatialExtent? coverage) + { + // Supported convention: UTM easting/northing in kilometres, southwest tile corner. + // The service must also publish a grid size, CRS and enclosing geographic coverage. + if (srs is not ("EPSG:25832" or "EPSG:25833") || coverage is null) + return null; + var match = Regex.Match(Path.GetFileNameWithoutExtension(url.LocalPath), @"_(\d{3})_(\d{4})$"); + if (!match.Success) + return null; + var x = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) * 1000.0; + var y = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture) * 1000.0; + var bounds = new BoundingBox2D(x, y, x + size, y + size); + var extent = new AtomSpatialExtent(srs, bounds); + var coverageBounds = TransformBounds(coverage, srs); + if (!Intersects(bounds, coverageBounds)) + throw new InvalidOperationException($"The declared ATOM grid and coverage disagree for '{url.LocalPath}'. Tile placement cannot be determined safely."); + return extent; + } + + public static AtomSpatialExtent? ReadExtent(XElement owner) + { + if (owner.Elements().Count(element => element.Name.Namespace == GeoRss && element.Name.LocalName is "box" or "polygon" or "where") > 1) + throw new InvalidOperationException("ATOM entry has multiple spatial extents; tile coverage is ambiguous."); + var box = owner.Element(GeoRss + "box"); + if (box is not null) + { + var values = Numbers(box.Value); + if (values.Length != 4) + throw new InvalidOperationException("Invalid ATOM GeoRSS box; expected south west north east."); + return Geographic(new BoundingBox2D(values[1], values[0], values[3], values[2])); + } + var polygon = owner.Element(GeoRss + "polygon"); + if (polygon is not null) + { + var values = Numbers(polygon.Value); + if (values.Length < 8 || values.Length % 2 != 0) + throw new InvalidOperationException("Invalid ATOM GeoRSS polygon."); + return Geographic(new BoundingBox2D( + values.Where((_, i) => i % 2 == 1).Min(), values.Where((_, i) => i % 2 == 0).Min(), + values.Where((_, i) => i % 2 == 1).Max(), values.Where((_, i) => i % 2 == 0).Max())); + } + var envelope = owner.Element(GeoRss + "where")?.Descendants().FirstOrDefault(element => element.Name.LocalName == "Envelope"); + if (envelope is null) + return null; + var lower = envelope.Elements().FirstOrDefault(element => element.Name.LocalName == "lowerCorner"); + var upper = envelope.Elements().FirstOrDefault(element => element.Name.LocalName == "upperCorner"); + var srs = (string?)envelope.Attribute("srsName") ?? "urn:ogc:def:crs:EPSG::4326"; + if (lower is null || upper is null) + throw new InvalidOperationException("ATOM GML envelope is missing its corners."); + var lo = Numbers(lower.Value); + var hi = Numbers(upper.Value); + if (lo.Length < 2 || hi.Length < 2) + throw new InvalidOperationException("Invalid ATOM GML envelope corners."); + var code = Regex.Match(srs, @"EPSG(?::|/)(?:[^:/]*[:/])?(\d+)$", RegexOptions.IgnoreCase); + var normalized = code.Success ? "EPSG:" + code.Groups[1].Value : srs.Contains("CRS84", StringComparison.OrdinalIgnoreCase) ? "EPSG:4326" : srs; + var swap = code.Success && (normalized is "EPSG:4326" or "EPSG:4258"); + var bounds = swap ? new BoundingBox2D(lo[1], lo[0], hi[1], hi[0]) : new BoundingBox2D(lo[0], lo[1], hi[0], hi[1]); + ValidateBounds(bounds); + return new AtomSpatialExtent(normalized, bounds); + } + + private static AtomSpatialExtent Geographic(BoundingBox2D bounds) + { + ValidateBounds(bounds); + if (bounds.MinX < -180 || bounds.MaxX > 180 || bounds.MinY < -90 || bounds.MaxY > 90) + throw new InvalidOperationException("ATOM geographic extent is outside longitude/latitude limits."); + return new AtomSpatialExtent("EPSG:4326", bounds); + } + + private static double[] Numbers(string value) => value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Select(number => double.Parse(number, NumberStyles.Float, CultureInfo.InvariantCulture)).ToArray(); + + public static void ValidateBounds(BoundingBox2D bounds) + { + if (!double.IsFinite(bounds.MinX) || !double.IsFinite(bounds.MinY) || !double.IsFinite(bounds.MaxX) || !double.IsFinite(bounds.MaxY) || + bounds.MinX >= bounds.MaxX || bounds.MinY >= bounds.MaxY) + throw new InvalidOperationException("Invalid or unsupported ATOM spatial bounds (including wrapped antimeridian extents)."); + } + + public static BoundingBox2D TransformBounds(AtomSpatialExtent extent, string targetSrs) + { + ValidateBounds(extent.Bounds); + var points = new List(); + // Sample edges as well as corners so projected/geographic tile selection stays conservative. + for (var i = 0; i <= 16; i++) + { + var t = i / 16.0; + var b = extent.Bounds; + Add(b.MinX + (b.MaxX - b.MinX) * t, b.MinY); + Add(b.MinX + (b.MaxX - b.MinX) * t, b.MaxY); + Add(b.MinX, b.MinY + (b.MaxY - b.MinY) * t); + Add(b.MaxX, b.MinY + (b.MaxY - b.MinY) * t); + } + return new BoundingBox2D(points.Min(p => p.X), points.Min(p => p.Y), points.Max(p => p.X), points.Max(p => p.Y)); + + void Add(double x, double y) + { + if (!SpatialReferenceTransform.TryTransformXY(extent.SrsName, targetSrs, x, y, out var tx, out var ty) || !double.IsFinite(tx) || !double.IsFinite(ty)) + throw new InvalidOperationException($"Unsupported ATOM coordinate transformation from '{extent.SrsName}' to '{targetSrs}'."); + points.Add(new Coordinate2D(tx, ty)); + } + } + + public static bool Intersects(BoundingBox2D a, BoundingBox2D b) => + a.MinX < b.MaxX && a.MaxX > b.MinX && a.MinY < b.MaxY && a.MaxY > b.MinY; +} diff --git a/RhinoSpatial.Core/Lod2GmlReader.cs b/RhinoSpatial.Core/Lod2GmlReader.cs index 4c82ef8..f7cf7bf 100644 --- a/RhinoSpatial.Core/Lod2GmlReader.cs +++ b/RhinoSpatial.Core/Lod2GmlReader.cs @@ -12,6 +12,13 @@ public record CityGmlSourceMetadata(string SrsName, BoundingBox2D? BoundingBox); public static class Lod2GmlReader { + public static bool IsCityGmlDocument(Stream stream) + { + using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + reader.MoveToContent(); + return reader.LocalName == "CityModel" && reader.NamespaceURI.StartsWith("http://www.opengis.net/citygml", StringComparison.Ordinal); + } + public static List ReadBuildings(string gmlText, string sourceLayerName) { return ReadBuildings(gmlText, sourceLayerName, null); diff --git a/RhinoSpatial.Core/SpatialReferenceTransform.cs b/RhinoSpatial.Core/SpatialReferenceTransform.cs index f170b81..2494901 100644 --- a/RhinoSpatial.Core/SpatialReferenceTransform.cs +++ b/RhinoSpatial.Core/SpatialReferenceTransform.cs @@ -99,7 +99,7 @@ public static bool TryTransformXY(string sourceSrs, string targetSrs, double x, }; } - private static string NormalizeSrs(string? srs) + public static string NormalizeSrs(string? srs) { if (string.IsNullOrWhiteSpace(srs)) { @@ -108,6 +108,14 @@ private static string NormalizeSrs(string? srs) var trimmed = srs.Trim(); + // AdV CityGML names combine a horizontal CRS with a height reference. + // Only normalize XY; source heights remain in their published datum. + var horizontalName = trimmed.Split('*')[0]; + if (horizontalName.Equals("urn:adv:crs:ETRS89_UTM32", System.StringComparison.OrdinalIgnoreCase)) + return "EPSG:25832"; + if (horizontalName.Equals("urn:adv:crs:ETRS89_UTM33", System.StringComparison.OrdinalIgnoreCase)) + return "EPSG:25833"; + if (trimmed.Contains("4326", System.StringComparison.OrdinalIgnoreCase)) { return "EPSG:4326"; diff --git a/RhinoSpatial.Core/TerrainMeshBuilder.cs b/RhinoSpatial.Core/TerrainMeshBuilder.cs new file mode 100644 index 0000000..0480136 --- /dev/null +++ b/RhinoSpatial.Core/TerrainMeshBuilder.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; + +namespace RhinoSpatial.Core +{ + public record TerrainMeshData( + List Vertices, + List<(int A, int B, int C, int D)> Faces); + + public static class TerrainMeshBuilder + { + public static TerrainMeshData Build( + TerrainRasterData raster, + BoundingBox2D sampleBounds, + SpatialContext2D context, + int maxGridSize, + double elevationBase) + { + if (raster.Width < 2 || raster.Height < 2 || + (long)raster.Width * raster.Height != raster.Elevations.Length) + { + throw new InvalidOperationException("Terrain requires a raster of at least 2x2 cells with matching elevation values."); + } + + if (maxGridSize < 2) + { + throw new ArgumentOutOfRangeException(nameof(maxGridSize)); + } + + var xs = SampleIndices(raster.Width, maxGridSize); + var ys = SampleIndices(raster.Height, maxGridSize); + var vertices = new List(xs.Count * ys.Count); + var faces = new List<(int A, int B, int C, int D)>(); + var vertexIndices = new int[xs.Count * ys.Count]; + Array.Fill(vertexIndices, -1); + var stepX = (sampleBounds.MaxX - sampleBounds.MinX) / (raster.Width - 1); + var stepY = (sampleBounds.MaxY - sampleBounds.MinY) / (raster.Height - 1); + var offsetX = context.UseAbsoluteCoordinates ? 0.0 : context.PlacementOrigin.X; + var offsetY = context.UseAbsoluteCoordinates ? 0.0 : context.PlacementOrigin.Y; + var offsetZ = context.UseAbsoluteCoordinates ? 0.0 : elevationBase; + + for (var row = 0; row < ys.Count; row++) + { + for (var col = 0; col < xs.Count; col++) + { + var elevation = raster.Elevations[ys[row] * raster.Width + xs[col]]; + if (IsNoData(raster, elevation)) + { + continue; + } + + var sourceX = sampleBounds.MinX + xs[col] * stepX; + var sourceY = sampleBounds.MaxY - ys[row] * stepY; + if (!SpatialReferenceTransform.TryTransformXY( + raster.SrsName, context.ResolvedSrs, sourceX, sourceY, out var x, out var y) || + !double.IsFinite(x) || !double.IsFinite(y)) + { + throw new InvalidOperationException($"Cannot transform terrain from '{raster.SrsName}' into Spatial Context '{context.ResolvedSrs}'."); + } + + vertexIndices[row * xs.Count + col] = vertices.Count; + vertices.Add(new Coordinate3D(x - offsetX, y - offsetY, elevation - offsetZ)); + } + } + + for (var row = 0; row < ys.Count - 1; row++) + { + for (var col = 0; col < xs.Count - 1; col++) + { + var index = row * xs.Count + col; + var a = vertexIndices[index]; + var b = vertexIndices[index + 1]; + var c = vertexIndices[index + xs.Count + 1]; + var d = vertexIndices[index + xs.Count]; + if (a >= 0 && b >= 0 && c >= 0 && d >= 0 && + HasCompleteCoverage(raster, xs[col], ys[row], xs[col + 1], ys[row + 1])) + { + faces.Add((a, b, c, d)); + } + } + } + + if (faces.Count == 0) + { + throw new InvalidOperationException("The terrain raster has no connected usable elevation cells to form a surface."); + } + + return new TerrainMeshData(vertices, faces); + } + + public static bool IsNoData(TerrainRasterData raster, float elevation) + { + return !float.IsFinite(elevation) || + (raster.NoDataValue.HasValue && Math.Abs(elevation - raster.NoDataValue.Value) < 1e-3); + } + + private static bool HasCompleteCoverage(TerrainRasterData raster, int minX, int minY, int maxX, int maxY) + { + // Downsampling must not bridge a NoData hole hidden between retained vertices. + for (var y = minY; y <= maxY; y++) + { + for (var x = minX; x <= maxX; x++) + { + if (IsNoData(raster, raster.Elevations[y * raster.Width + x])) + { + return false; + } + } + } + + return true; + } + + private static List SampleIndices(int count, int limit) + { + var stride = Math.Max(1, (int)Math.Ceiling((count - 1) / (double)(limit - 1))); + var indices = new List(); + for (var index = 0; index < count - 1; index += stride) + { + indices.Add(index); + } + + indices.Add(count - 1); + return indices; + } + } +} diff --git a/RhinoSpatial.Core/TerrainTextGridReader.cs b/RhinoSpatial.Core/TerrainTextGridReader.cs index c4b0867..18c708a 100644 --- a/RhinoSpatial.Core/TerrainTextGridReader.cs +++ b/RhinoSpatial.Core/TerrainTextGridReader.cs @@ -52,71 +52,48 @@ public static TerrainTextGridReadResult ReadRaster(string filePath, string cover private static TerrainTextGridReadResult ReadEsriAsciiGrid(string filePath, string coverageId, string sourceSrs) { using var reader = new StreamReader(filePath); - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - while (headers.Count < 6 && reader.ReadLine() is { } headerLine) + var header = ReadEsriAsciiHeader(reader, out var firstDataLine); + var elevations = new float[GetCellCount(header.Width, header.Height)]; + var count = 0; + var line = firstDataLine; + while (line is not null) { - var parts = SplitFields(headerLine); - if (parts.Length < 2) + foreach (var value in SplitFields(line)) { - continue; - } - - headers[parts[0]] = parts[1]; - } - - var width = ReadRequiredInt(headers, "ncols"); - var height = ReadRequiredInt(headers, "nrows"); - var xll = ReadRequiredDouble(headers, "xllcorner", "xllcenter"); - var yll = ReadRequiredDouble(headers, "yllcorner", "yllcenter"); - var cellSize = ReadRequiredDouble(headers, "cellsize"); - var usesCellCenter = - headers.ContainsKey("xllcenter") || - headers.ContainsKey("yllcenter"); - var noData = TryReadDouble(headers, "NODATA_value"); - var elevations = new float[width * height]; - var row = 0; - - while (row < height && reader.ReadLine() is { } line) - { - var parts = SplitFields(line); - if (parts.Length == 0) - { - continue; - } + if (count >= elevations.Length) + { + throw new InvalidOperationException("The Esri ASCII Grid contains more cell values than ncols and nrows specify."); + } - for (var col = 0; col < Math.Min(width, parts.Length); col++) - { - elevations[row * width + col] = ParseFloat(parts[col]); + elevations[count++] = ParseFloat(value); } - row++; + line = reader.ReadLine(); } - if (row < height) + if (count != elevations.Length) { - throw new InvalidOperationException("The Esri ASCII Grid ended before all rows were read."); + throw new InvalidOperationException($"The Esri ASCII Grid contains {count} cell values; expected {elevations.Length}."); } - var minX = usesCellCenter ? xll - (cellSize * 0.5) : xll; - var minY = usesCellCenter ? yll - (cellSize * 0.5) : yll; - var maxX = minX + ((width - 1) * cellSize); - var maxY = minY + ((height - 1) * cellSize); + // Terrain vertices represent cell centers; reference metadata covers the full cell footprint. + var bounds = new BoundingBox2D( + header.Footprint.MinX + header.CellSize * 0.5, + header.Footprint.MinY + header.CellSize * 0.5, + header.Footprint.MaxX - header.CellSize * 0.5, + header.Footprint.MaxY - header.CellSize * 0.5); var raster = new TerrainRasterData( coverageId, sourceSrs, - width, - height, - new Coordinate2D(minX, maxY), - new Coordinate2D(cellSize, 0.0), - new Coordinate2D(0.0, -cellSize), - noData, + header.Width, + header.Height, + new Coordinate2D(bounds.MinX, bounds.MaxY), + new Coordinate2D(header.CellSize, 0.0), + new Coordinate2D(0.0, -header.CellSize), + header.NoDataValue, elevations); - return new TerrainTextGridReadResult( - raster, - new BoundingBox2D(minX, minY, maxX, maxY), - sourceSrs, - "Esri ASCII Grid"); + return new TerrainTextGridReadResult(raster, bounds, sourceSrs, "Esri ASCII Grid"); } private static TerrainTextGridSourceMetadata ReadEsriAsciiGridMetadata( @@ -124,32 +101,89 @@ private static TerrainTextGridSourceMetadata ReadEsriAsciiGridMetadata( string sourceSrs) { using var reader = new StreamReader(filePath); + var header = ReadEsriAsciiHeader(reader, out _); + return new TerrainTextGridSourceMetadata(header.Footprint, sourceSrs, "Esri ASCII Grid"); + } + + private sealed record AsciiGridHeader( + int Width, + int Height, + double CellSize, + double NoDataValue, + BoundingBox2D Footprint); + + private static AsciiGridHeader ReadEsriAsciiHeader(StreamReader reader, out string? firstDataLine) + { var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - while (headers.Count < 6 && reader.ReadLine() is { } headerLine) + firstDataLine = null; + while (reader.ReadLine() is { } line) { - var parts = SplitFields(headerLine); - if (parts.Length >= 2) + var parts = SplitFields(line); + if (parts.Length == 0) { - headers[parts[0]] = parts[1]; + continue; + } + + var key = parts[0].ToLowerInvariant(); + if (key is not ("ncols" or "nrows" or "xllcorner" or "xllcenter" or "yllcorner" or "yllcenter" or "cellsize" or "nodata_value")) + { + firstDataLine = line; + break; + } + + if (parts.Length != 2 || !headers.TryAdd(key, parts[1])) + { + throw new InvalidOperationException($"Invalid or duplicate Esri ASCII Grid header '{key}'."); } } var width = ReadRequiredInt(headers, "ncols"); var height = ReadRequiredInt(headers, "nrows"); + GetCellCount(width, height); + var cellSize = ReadRequiredDouble(headers, "cellsize"); + if (cellSize <= 0.0) + { + throw new InvalidOperationException("The terrain grid cellsize must be greater than zero."); + } + + var usesCenters = headers.ContainsKey("xllcenter") && headers.ContainsKey("yllcenter"); + var usesCorners = headers.ContainsKey("xllcorner") && headers.ContainsKey("yllcorner"); + if ((!usesCenters && !usesCorners) || + (usesCenters && (headers.ContainsKey("xllcorner") || headers.ContainsKey("yllcorner"))) || + (usesCorners && (headers.ContainsKey("xllcenter") || headers.ContainsKey("yllcenter")))) + { + throw new InvalidOperationException("The terrain grid origin must use matching xllcorner/yllcorner or xllcenter/yllcenter headers."); + } + var xll = ReadRequiredDouble(headers, "xllcorner", "xllcenter"); var yll = ReadRequiredDouble(headers, "yllcorner", "yllcenter"); - var cellSize = ReadRequiredDouble(headers, "cellsize"); - var usesCellCenter = headers.ContainsKey("xllcenter") || headers.ContainsKey("yllcenter"); - var minX = usesCellCenter ? xll - (cellSize * 0.5) : xll; - var minY = usesCellCenter ? yll - (cellSize * 0.5) : yll; - return new TerrainTextGridSourceMetadata( - new BoundingBox2D( - minX, - minY, - minX + ((width - 1) * cellSize), - minY + ((height - 1) * cellSize)), - sourceSrs, - "Esri ASCII Grid"); + var minX = usesCenters ? xll - cellSize * 0.5 : xll; + var minY = usesCenters ? yll - cellSize * 0.5 : yll; + var maxX = minX + width * cellSize; + var maxY = minY + height * cellSize; + if (!double.IsFinite(minX) || !double.IsFinite(minY) || !double.IsFinite(maxX) || !double.IsFinite(maxY)) + { + throw new InvalidOperationException("The terrain grid extent must contain finite coordinates."); + } + + var noData = TryReadDouble(headers, "NODATA_value"); + if (headers.ContainsKey("NODATA_value") && !noData.HasValue) + { + throw new InvalidOperationException("The terrain grid NODATA_value must be numeric."); + } + + return new AsciiGridHeader(width, height, cellSize, noData ?? -9999.0, new BoundingBox2D(minX, minY, maxX, maxY)); + } + + private static int GetCellCount(int width, int height) + { + var count = (long)width * height; + if (count > Array.MaxLength) + { + throw new InvalidOperationException("The terrain grid contains too many cells to load into memory."); + } + + return (int)count; } private static TerrainTextGridSourceMetadata ReadXyzGridMetadata( @@ -170,6 +204,7 @@ private static TerrainTextGridSourceMetadata ReadXyzGridMetadata( continue; } + RequireFinitePosition(x, y); minX = !minX.HasValue ? x : Math.Min(minX.Value, x); minY = !minY.HasValue ? y : Math.Min(minY.Value, y); maxX = !maxX.HasValue ? x : Math.Max(maxX.Value, x); @@ -201,6 +236,7 @@ private static TerrainTextGridReadResult ReadXyzGrid(string filePath, string cov continue; } + RequireFinitePosition(x, y); samples.Add((x, y, z)); } @@ -216,6 +252,8 @@ private static TerrainTextGridReadResult ReadXyzGrid(string filePath, string cov throw new InvalidOperationException("The XYZ/CSV terrain grid must contain at least a 2x2 regular grid."); } + RequireRegularAxis(xs); + RequireRegularAxis(ys); var width = xs.Count; var height = ys.Count; var xIndex = xs @@ -224,7 +262,7 @@ private static TerrainTextGridReadResult ReadXyzGrid(string filePath, string cov var yIndex = ys .Select((value, index) => (value, index)) .ToDictionary(item => item.value, item => item.index); - var elevations = Enumerable.Repeat(float.NaN, width * height).ToArray(); + var elevations = Enumerable.Repeat(float.NaN, GetCellCount(width, height)).ToArray(); foreach (var sample in samples) { @@ -253,6 +291,28 @@ private static TerrainTextGridReadResult ReadXyzGrid(string filePath, string cov "XYZ/CSV terrain grid"); } + private static void RequireFinitePosition(double x, double y) + { + if (!double.IsFinite(x) || !double.IsFinite(y)) + { + throw new InvalidOperationException("XYZ/CSV terrain positions must contain finite x,y coordinates."); + } + } + + private static void RequireRegularAxis(IReadOnlyList coordinates) + { + var step = (coordinates[^1] - coordinates[0]) / (coordinates.Count - 1); + var tolerance = Math.Max(Math.Abs(step) * 1e-6, 1e-9); + for (var index = 1; index < coordinates.Count; index++) + { + if (!double.IsFinite(step) || + Math.Abs(coordinates[index] - (coordinates[0] + index * step)) > tolerance) + { + throw new InvalidOperationException("The XYZ/CSV terrain file must use a regular grid with evenly spaced x and y coordinates."); + } + } + } + private static string[] SplitFields(string line) { return line @@ -262,9 +322,9 @@ private static string[] SplitFields(string line) private static int ReadRequiredInt(IReadOnlyDictionary headers, string key) { if (!headers.TryGetValue(key, out var value) || - !int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + !int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) { - throw new InvalidOperationException($"The terrain grid header is missing '{key}'."); + throw new InvalidOperationException($"The terrain grid header '{key}' must be an integer greater than zero."); } return parsed; @@ -275,13 +335,13 @@ private static double ReadRequiredDouble(IReadOnlyDictionary hea foreach (var key in keys) { if (headers.TryGetValue(key, out var value) && - double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) { return parsed; } } - throw new InvalidOperationException($"The terrain grid header is missing '{string.Join("' or '", keys)}'."); + throw new InvalidOperationException($"The terrain grid header '{string.Join("' or '", keys)}' must contain a finite number."); } private static double? TryReadDouble(IReadOnlyDictionary headers, string key) @@ -296,7 +356,7 @@ private static float ParseFloat(string value) { return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed - : float.NaN; + : throw new InvalidOperationException($"The terrain grid cell value '{value}' is not numeric."); } } } diff --git a/RhinoSpatial/LoadTerrainComponent.cs b/RhinoSpatial/LoadTerrainComponent.cs index 305fcb4..ea62d5f 100644 --- a/RhinoSpatial/LoadTerrainComponent.cs +++ b/RhinoSpatial/LoadTerrainComponent.cs @@ -193,7 +193,7 @@ private async Task ComputeForSourceAsync( var description = await _wcsClient.LoadCoverageDescriptionAsync(options); - var requestBoundingBox = ResolveRequestedBoundingBox(spatialContext, description, out var placementOrigin); + var requestBoundingBox = ResolveRequestedBoundingBox(spatialContext, description); options.SrsName = description.NativeSrs; options.AxisXLabel = description.AxisXLabel; @@ -202,28 +202,23 @@ private async Task ComputeForSourceAsync( var coverage = await _wcsClient.DownloadCoverageAsync(options); - var elevationBase = spatialContext.UseAbsoluteCoordinates - ? 0.0 - : SpatialElevationBaselineCache.ResolveOrStore( - spatialContext, - ResolveElevationBase(coverage.Raster)); + var mesh = BuildTerrainMesh( + coverage.Raster, + requestBoundingBox, + spatialContext, + out var elevationBase); SpatialTerrainCache.Store( spatialContext, description.NativeSrs, requestBoundingBox, coverage.Raster, elevationBase); - var mesh = BuildTerrainMesh( - coverage.Raster, - requestBoundingBox, - placementOrigin, - spatialContext.UseAbsoluteCoordinates, - elevationBase); - var status = BuildStatusMessage(coverageId, spatialContext.UseAbsoluteCoordinates, coverage.UsedCachedFile); + var status = BuildStatusMessage(coverageId, spatialContext.UseAbsoluteCoordinates, coverage.UsedCachedFile) + + BuildMissingElevationNote(coverage.Raster); return new SolveResults { - TerrainMeshes = mesh is null ? new List() : new List { mesh }, + TerrainMeshes = new List { mesh }, Status = $"{source.CreateStatusPrefix()}{status}" }; } @@ -263,37 +258,7 @@ private SolveResults ComputeLocalGeoTiffTerrain( }; } - var elevationBase = spatialContext.UseAbsoluteCoordinates - ? 0.0 - : SpatialElevationBaselineCache.ResolveOrStore( - spatialContext, - ResolveElevationBase(raster)); - - Mesh? mesh; - if (RhinoSpatialContextTools.NormalizeSrsKey(rasterInfo.SrsName) == "EPSG:4326") - { - mesh = BuildProjectedTerrainMesh( - raster, - rasterInfo.BoundingBox, - spatialContext, - elevationBase); - } - else - { - var placementOrigin = RhinoSpatialContextTools.TryResolveBoundingBoxForSrs( - spatialContext, - rasterInfo.SrsName, - out _, - out var resolvedPlacementOrigin) - ? resolvedPlacementOrigin - : spatialContext.PlacementOrigin; - mesh = BuildTerrainMesh( - raster, - rasterInfo.BoundingBox, - placementOrigin, - spatialContext.UseAbsoluteCoordinates, - elevationBase); - } + var mesh = BuildTerrainMesh(raster, rasterInfo.BoundingBox, spatialContext, out var elevationBase); SpatialTerrainCache.Store( spatialContext, @@ -304,8 +269,8 @@ private SolveResults ComputeLocalGeoTiffTerrain( return new SolveResults { - TerrainMeshes = mesh is null ? new List() : new List { mesh }, - Status = $"{source.CreateStatusPrefix()}Loaded local terrain GeoTIFF '{Path.GetFileName(source.BaseUrl)}' ({rasterInfo.Width}x{rasterInfo.Height}, {rasterInfo.SrsName}) {BuildTerrainAlignmentNote(spatialContext.UseAbsoluteCoordinates)}", + TerrainMeshes = new List { mesh }, + Status = $"{source.CreateStatusPrefix()}Loaded local terrain GeoTIFF '{Path.GetFileName(source.BaseUrl)}' ({rasterInfo.Width}x{rasterInfo.Height}, {rasterInfo.SrsName}) {BuildTerrainAlignmentNote(spatialContext.UseAbsoluteCoordinates)}{BuildMissingElevationNote(raster)}", MessageLevel = GH_RuntimeMessageLevel.Remark }; } @@ -344,36 +309,7 @@ private SolveResults ComputeLocalTextGridTerrain( }; } - var elevationBase = spatialContext.UseAbsoluteCoordinates - ? 0.0 - : SpatialElevationBaselineCache.ResolveOrStore( - spatialContext, - ResolveElevationBase(readResult.Raster)); - Mesh? mesh; - if (RhinoSpatialContextTools.NormalizeSrsKey(readResult.SourceSrs) == "EPSG:4326") - { - mesh = BuildProjectedTerrainMesh( - readResult.Raster, - readResult.BoundingBox, - spatialContext, - elevationBase); - } - else - { - var placementOrigin = RhinoSpatialContextTools.TryResolveBoundingBoxForSrs( - spatialContext, - readResult.SourceSrs, - out _, - out var resolvedPlacementOrigin) - ? resolvedPlacementOrigin - : spatialContext.PlacementOrigin; - mesh = BuildTerrainMesh( - readResult.Raster, - readResult.BoundingBox, - placementOrigin, - spatialContext.UseAbsoluteCoordinates, - elevationBase); - } + var mesh = BuildTerrainMesh(readResult.Raster, readResult.BoundingBox, spatialContext, out var elevationBase); SpatialTerrainCache.Store( spatialContext, @@ -384,8 +320,8 @@ private SolveResults ComputeLocalTextGridTerrain( return new SolveResults { - TerrainMeshes = mesh is null ? new List() : new List { mesh }, - Status = $"{source.CreateStatusPrefix()}Loaded local terrain {readResult.FormatLabel} '{Path.GetFileName(source.BaseUrl)}' ({readResult.Raster.Width}x{readResult.Raster.Height}, {readResult.SourceSrs}) {BuildTerrainAlignmentNote(spatialContext.UseAbsoluteCoordinates)}", + TerrainMeshes = new List { mesh }, + Status = $"{source.CreateStatusPrefix()}Loaded local terrain {readResult.FormatLabel} '{Path.GetFileName(source.BaseUrl)}' ({readResult.Raster.Width}x{readResult.Raster.Height}, {readResult.SourceSrs}) {BuildTerrainAlignmentNote(spatialContext.UseAbsoluteCoordinates)}{BuildMissingElevationNote(readResult.Raster)}", MessageLevel = GH_RuntimeMessageLevel.Remark }; } @@ -426,30 +362,24 @@ private async Task ComputeGlobalTerrainFallbackAsync( + "Connect an explicit terrain source for this area."); } - var elevationBase = spatialContext.UseAbsoluteCoordinates - ? 0.0 - : SpatialElevationBaselineCache.ResolveOrStore( - spatialContext, - ResolveElevationBase(raster)); - + var mesh = BuildTerrainMesh( + raster, + spatialContext.Wgs84BoundingBox, + spatialContext, + out var elevationBase); SpatialTerrainCache.Store( spatialContext, raster.SrsName, spatialContext.Wgs84BoundingBox, raster, elevationBase); - - var mesh = BuildProjectedTerrainMesh( - raster, - spatialContext.Wgs84BoundingBox, - spatialContext, - elevationBase); var status = BuildStatusMessage(raster.CoverageId, spatialContext.UseAbsoluteCoordinates, false) + + BuildMissingElevationNote(raster) + " The built-in fallback is intended for quick context; use explicit terrain for project-grade elevation."; return new SolveResults { - TerrainMeshes = mesh is null ? new List() : new List { mesh }, + TerrainMeshes = new List { mesh }, Status = $"{source.CreateStatusPrefix()}{status}" }; } @@ -474,18 +404,24 @@ private static string BuildTerrainAlignmentNote(bool useAbsoluteCoordinates) : "aligned to the shared local terrain/building elevation baseline."; } + private static string BuildMissingElevationNote(TerrainRasterData raster) + { + var missing = raster.Elevations.Length - CountValidElevations(raster); + return missing == 0 + ? string.Empty + : $" The source contains {missing} missing elevation sample(s); affected areas are left open in the terrain surface."; + } + private static BoundingBox2D ResolveRequestedBoundingBox( SpatialContext2D spatialContext, - WcsCoverageDescription description, - out Coordinate2D placementOrigin) + WcsCoverageDescription description) { - if (RhinoSpatialContextTools.TryResolveBoundingBoxForSrs(spatialContext, description.NativeSrs, out var bbox, out placementOrigin)) + if (RhinoSpatialContextTools.TryResolveBoundingBoxForSrs(spatialContext, description.NativeSrs, out var bbox, out _)) { return ClampBoundingBox(bbox, description.NativeBoundingBox); } - placementOrigin = spatialContext.PlacementOrigin; - return ClampBoundingBox(spatialContext.RequestBoundingBox, description.NativeBoundingBox); + throw new InvalidOperationException($"Cannot resolve the selected area in terrain coordinate system '{description.NativeSrs}'."); } private static BoundingBox2D ClampBoundingBox(BoundingBox2D requested, BoundingBox2D nativeBounds) @@ -497,148 +433,41 @@ private static BoundingBox2D ClampBoundingBox(BoundingBox2D requested, BoundingB if (maxX <= minX || maxY <= minY) { - return requested; + throw new InvalidOperationException("The terrain coverage does not overlap the selected Spatial Context area."); } return new BoundingBox2D(minX, minY, maxX, maxY); } - private static Mesh? BuildTerrainMesh( - TerrainRasterData raster, - BoundingBox2D requestBoundingBox, - Coordinate2D placementOrigin, - bool useAbsoluteCoordinates, - double elevationBase) - { - var width = raster.Width; - var height = raster.Height; - if (width <= 1 || height <= 1) - { - return null; - } - - var safeMaxGrid = Math.Clamp(InternalGridSize, 64, DefaultMaxGridSizeLimit); - var strideX = Math.Max(1, (int)Math.Ceiling(width / (double)safeMaxGrid)); - var strideY = Math.Max(1, (int)Math.Ceiling(height / (double)safeMaxGrid)); - - var sampleWidth = (int)Math.Ceiling(width / (double)strideX); - var sampleHeight = (int)Math.Ceiling(height / (double)strideY); - - var spanX = requestBoundingBox.MaxX - requestBoundingBox.MinX; - var spanY = requestBoundingBox.MaxY - requestBoundingBox.MinY; - var cellSizeX = spanX / (width - 1); - var cellSizeY = spanY / (height - 1); - - var offsetX = useAbsoluteCoordinates ? 0.0 : placementOrigin.X; - var offsetY = useAbsoluteCoordinates ? 0.0 : placementOrigin.Y; - var mesh = new Mesh(); - - for (var y = 0; y < sampleHeight; y++) - { - var sourceY = Math.Min(height - 1, y * strideY); - var worldY = requestBoundingBox.MaxY - sourceY * cellSizeY - offsetY; - - for (var x = 0; x < sampleWidth; x++) - { - var sourceX = Math.Min(width - 1, x * strideX); - var worldX = requestBoundingBox.MinX + sourceX * cellSizeX - offsetX; - var elevation = raster.Elevations[sourceY * width + sourceX]; - - if (IsNoDataElevation(raster, elevation)) - { - elevation = (float)elevationBase; - } - - mesh.Vertices.Add(worldX, worldY, elevation - elevationBase); - } - } - - for (var y = 0; y < sampleHeight - 1; y++) - { - for (var x = 0; x < sampleWidth - 1; x++) - { - var index0 = y * sampleWidth + x; - var index1 = index0 + 1; - var index2 = index0 + sampleWidth + 1; - var index3 = index0 + sampleWidth; - mesh.Faces.AddFace(index0, index1, index2, index3); - } - } - - mesh.Normals.ComputeNormals(); - mesh.Compact(); - return mesh; - } - - private static Mesh? BuildProjectedTerrainMesh( + private static Mesh BuildTerrainMesh( TerrainRasterData raster, - BoundingBox2D rasterBoundingBox4326, + BoundingBox2D sampleBounds, SpatialContext2D spatialContext, - double elevationBase) + out double elevationBase) { - var width = raster.Width; - var height = raster.Height; - if (width <= 1 || height <= 1) - { - return null; - } - - var safeMaxGrid = Math.Clamp(InternalGridSize, 64, DefaultMaxGridSizeLimit); - var strideX = Math.Max(1, (int)Math.Ceiling(width / (double)safeMaxGrid)); - var strideY = Math.Max(1, (int)Math.Ceiling(height / (double)safeMaxGrid)); - - var sampleWidth = (int)Math.Ceiling(width / (double)strideX); - var sampleHeight = (int)Math.Ceiling(height / (double)strideY); - var spanX = rasterBoundingBox4326.MaxX - rasterBoundingBox4326.MinX; - var spanY = rasterBoundingBox4326.MaxY - rasterBoundingBox4326.MinY; - var cellSizeX = spanX / (width - 1); - var cellSizeY = spanY / (height - 1); - var offsetX = spatialContext.UseAbsoluteCoordinates ? 0.0 : spatialContext.PlacementOrigin.X; - var offsetY = spatialContext.UseAbsoluteCoordinates ? 0.0 : spatialContext.PlacementOrigin.Y; + var candidateBase = spatialContext.UseAbsoluteCoordinates ? 0.0 : ResolveElevationBase(raster); + var data = TerrainMeshBuilder.Build( + raster, + sampleBounds, + spatialContext, + Math.Clamp(InternalGridSize, 64, DefaultMaxGridSizeLimit), + candidateBase); + elevationBase = spatialContext.UseAbsoluteCoordinates + ? 0.0 + : SpatialElevationBaselineCache.ResolveOrStore(spatialContext, candidateBase); + var elevationAdjustment = candidateBase - elevationBase; var mesh = new Mesh(); - - for (var y = 0; y < sampleHeight; y++) + foreach (var vertex in data.Vertices) { - var sourceY = Math.Min(height - 1, y * strideY); - var latitude = rasterBoundingBox4326.MaxY - sourceY * cellSizeY; - - for (var x = 0; x < sampleWidth; x++) - { - var sourceX = Math.Min(width - 1, x * strideX); - var longitude = rasterBoundingBox4326.MinX + sourceX * cellSizeX; - if (!SpatialReferenceTransform.TryTransformXY( - "EPSG:4326", - spatialContext.ResolvedSrs, - longitude, - latitude, - out var worldX, - out var worldY)) - { - return null; - } - - var elevation = raster.Elevations[sourceY * width + sourceX]; - if (IsNoDataElevation(raster, elevation)) - { - elevation = (float)elevationBase; - } - - mesh.Vertices.Add(worldX - offsetX, worldY - offsetY, elevation - elevationBase); - } + mesh.Vertices.Add(vertex.X, vertex.Y, vertex.Z + elevationAdjustment); } - for (var y = 0; y < sampleHeight - 1; y++) + foreach (var face in data.Faces) { - for (var x = 0; x < sampleWidth - 1; x++) - { - var index0 = y * sampleWidth + x; - var index1 = index0 + 1; - var index2 = index0 + sampleWidth + 1; - var index3 = index0 + sampleWidth; - mesh.Faces.AddFace(index0, index1, index2, index3); - } + mesh.Faces.AddFace(face.A, face.B, face.C, face.D); } + mesh.Vertices.CullUnused(); mesh.Normals.ComputeNormals(); mesh.Compact(); return mesh; @@ -650,7 +479,7 @@ private static double ResolveElevationBase(TerrainRasterData raster) foreach (var elevation in raster.Elevations) { - if (IsNoDataElevation(raster, elevation)) + if (TerrainMeshBuilder.IsNoData(raster, elevation)) { continue; } @@ -661,7 +490,12 @@ private static double ResolveElevationBase(TerrainRasterData raster) } } - return double.IsInfinity(minZ) ? 0.0 : minZ; + if (double.IsInfinity(minZ)) + { + throw new InvalidOperationException("The terrain raster contains no usable elevation samples."); + } + + return minZ; } private static int CountValidElevations(TerrainRasterData raster) @@ -669,7 +503,7 @@ private static int CountValidElevations(TerrainRasterData raster) var validCount = 0; foreach (var elevation in raster.Elevations) { - if (IsNoDataElevation(raster, elevation)) + if (TerrainMeshBuilder.IsNoData(raster, elevation)) { continue; } @@ -680,19 +514,6 @@ private static int CountValidElevations(TerrainRasterData raster) return validCount; } - private static bool IsNoDataElevation(TerrainRasterData raster, float elevation) - { - if (float.IsNaN(elevation) || float.IsInfinity(elevation)) - { - return true; - } - - return raster.NoDataValue.HasValue && - (double.IsNaN(raster.NoDataValue.Value) - ? float.IsNaN(elevation) - : Math.Abs(elevation - raster.NoDataValue.Value) < 1e-3); - } - private record RequestData(IReadOnlyList Sources, SpatialContext2D SpatialContext); public override Guid ComponentGuid => new Guid("e6941d59-50c0-46f4-96fa-9546a0f54f9d"); diff --git a/RhinoSpatial/Lod2LoadComponent.cs b/RhinoSpatial/Lod2LoadComponent.cs index 14eaf10..7e139d6 100644 --- a/RhinoSpatial/Lod2LoadComponent.cs +++ b/RhinoSpatial/Lod2LoadComponent.cs @@ -6,6 +6,7 @@ using System.IO.Compression; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using GH_IO.Serialization; using Grasshopper.Kernel; @@ -20,6 +21,7 @@ public class Lod2LoadComponent : GH_TaskCapableComponent? LocalSourcePaths { get; init; } } private sealed record LocalCityGmlCandidate( @@ -103,9 +107,9 @@ public Lod2LoadComponent() protected override void RegisterInputParams(GH_InputParamManager pManager) { - pManager.AddTextParameter("LoD2 Source", "LoD2 Source", "LoD2 source URL, local CityGML/GML/XML/CityJSON file, folder, or ZIP archive. If left empty, RhinoSpatial will try to inherit a WFS URL from the connected Layer input.", GH_ParamAccess.item); - pManager.AddTextParameter("Layer", "Layer", "Optional LoD2 building layer name for WFS sources, or local layer label for file/folder/ZIP sources.", GH_ParamAccess.item, string.Empty); - pManager.AddTextParameter("Spatial Context", "Spatial Context", "Shared RhinoSpatial spatial context. LoD2 requests use EPSG:7423/4326 internally, so any Spatial Context created from the map helper will align correctly.", GH_ParamAccess.item); + pManager.AddTextParameter("LoD2 Source", "LoD2 Source", "LoD2 WFS or ATOM service URL, local CityGML/GML/XML/CityJSON file, folder, or ZIP archive. If left empty, RhinoSpatial will try to inherit a WFS URL from the connected Layer input.", GH_ParamAccess.item); + pManager.AddTextParameter("Layer", "Layer", "Optional LoD2 building layer name for WFS sources, or output label for ATOM/local sources.", GH_ParamAccess.item, string.Empty); + pManager.AddTextParameter("Spatial Context", "Spatial Context", "Shared RhinoSpatial study area used to select, filter and align LoD2 buildings.", GH_ParamAccess.item); pManager[0].Optional = true; pManager[1].Optional = true; @@ -141,16 +145,19 @@ protected override void SolveInstance(IGH_DataAccess dataAccess) if (InPreSolve) { - Task task = Task.Run(() => ComputeSafe(requestData), CancelToken); + var cancellationToken = CancelToken; + Task task = Task.Run(() => ComputeSafe(requestData, cancellationToken), cancellationToken); TaskList.Add(task); return; } if (!GetSolveResults(dataAccess, out SolveResults result)) { - result = ComputeSafe(requestData); + result = ComputeSafe(requestData, CancelToken); } + Message = null; + if (!string.IsNullOrWhiteSpace(result.Status) && result.MessageLevel.HasValue) { AddRuntimeMessage(result.MessageLevel.Value, result.Status); @@ -192,7 +199,7 @@ private bool TryGetRequestData(IGH_DataAccess dataAccess, out RequestData reques if (string.IsNullOrWhiteSpace(source)) { - AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "LoD2 Source is required. Connect a LoD2 WFS URL, local CityGML/GML/XML file, folder, ZIP archive, or a layer input that carries a service URL."); + AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "LoD2 Source is required. Connect a LoD2 WFS or ATOM URL, local CityGML/GML/XML file, folder, ZIP archive, or a layer input that carries a service URL."); return false; } @@ -222,7 +229,7 @@ private bool TryGetRequestData(IGH_DataAccess dataAccess, out RequestData reques return true; } - private SolveResults Compute(RequestData requestData) + private SolveResults Compute(RequestData requestData, CancellationToken cancellationToken) { if (requestData.SourceKind != Lod2SourceKind.Url) { @@ -234,6 +241,14 @@ private SolveResults Compute(RequestData requestData) return ComputeLocalCityGmlSource(requestData); } + // Explicit WFS URLs retain their existing request path without an extra discovery GET. + if (!System.Text.RegularExpressions.Regex.IsMatch(requestData.Source, @"[?&]service=wfs(?:&|$)", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + var selection = _atomClient.TrySelectAsync(requestData.Source, requestData.SpatialContext, cancellationToken).GetAwaiter().GetResult(); + if (selection is not null) + return ComputeAtomSource(requestData, selection, cancellationToken); + } + var capabilities = _wfsClient.LoadCapabilitiesAsync(requestData.Source).GetAwaiter().GetResult(); var resolvedLayer = ResolveLayer(capabilities, requestData.LayerName); var requestSrs = ResolveRequestSrs(resolvedLayer.LayerInfo, requestData.SpatialContext); @@ -303,6 +318,57 @@ private SolveResults Compute(RequestData requestData) }; } + private SolveResults ComputeAtomSource(RequestData requestData, Lod2AtomSelection selection, CancellationToken cancellationToken) + { + if (selection.Tiles.Count == 0) + return new SolveResults + { + Status = $"No ATOM building tiles intersect the Spatial Context. Inspected {selection.FeedCount} feed(s) and {selection.DownloadCount} download entries; no archives downloaded.", + MessageLevel = GH_RuntimeMessageLevel.Warning + }; + + var progressActive = 1; + IReadOnlyList downloaded; + try + { + downloaded = _atomClient.DownloadAsync(selection, progress => + { + // Grasshopper can wait for this task on the UI thread. Queue the + // repaint without waiting for that thread to become available. + Eto.Forms.Application.Instance.AsyncInvoke(() => + { + if (Volatile.Read(ref progressActive) != 0 && !cancellationToken.IsCancellationRequested && OnPingDocument() is not null) + { + Message = progress; + OnDisplayExpired(false); + } + }); + }, cancellationToken).GetAwaiter().GetResult(); + } + finally + { + Volatile.Write(ref progressActive, 0); + } + cancellationToken.ThrowIfCancellationRequested(); + var localRequest = new RequestData + { + Source = downloaded[0].FilePath, + SourceKind = Lod2SourceKind.Zip, + LocalSourcePaths = downloaded.Select(tile => tile.FilePath).ToList(), + SpatialContext = requestData.SpatialContext, + LayerName = requestData.LayerName + }; + var result = ComputeLocalCityGmlSource(localRequest); + var cachedCount = downloaded.Count(tile => tile.UsedCache); + return new SolveResults + { + BrepTree = result.BrepTree, + BuildingCount = result.BuildingCount, + MessageLevel = result.MessageLevel, + Status = $"ATOM selected {selection.Tiles.Count}/{selection.DownloadCount} tile(s) from {selection.FeedCount} feed(s); downloaded {downloaded.Count - cachedCount}, reused {cachedCount} cached archive(s). Tiles: {string.Join(", ", downloaded.Select(tile => Path.GetFileName(tile.Tile.Url.LocalPath)))}. Cache: {Lod2AtomClient.DefaultCacheDirectory}. {result.Status}" + }; + } + private SolveResults ComputeLocalCityGmlSource(RequestData requestData) { var timings = new LocalCityGmlTimings(); @@ -381,6 +447,9 @@ private SolveResults ComputeLocalCityGmlSource(RequestData requestData) } timings.ParseAndBuildingFilter = stopwatch.Elapsed; + if (requestData.LocalSourcePaths is not null && failedFileCount > 0) + throw new InvalidOperationException($"Failed to parse {failedFileCount} downloaded ATOM CityGML file(s): {string.Join(", ", failedFileNames)}. No partial building result was returned."); + stopwatch.Restart(); var buildings = FilterBuildingSurfacesToRequestBounds(returnedBuildings, requestBoundingBox); timings.SurfaceFilter = stopwatch.Elapsed; @@ -429,11 +498,11 @@ private SolveResults ComputeLocalCityGmlSource(RequestData requestData) }; } - private SolveResults ComputeSafe(RequestData requestData) + private SolveResults ComputeSafe(RequestData requestData, CancellationToken cancellationToken) { try { - return Compute(requestData); + return Compute(requestData, cancellationToken); } catch (Exception ex) { @@ -830,7 +899,7 @@ private static string BuildLocalCityGmlStatusMessage( private static string ResolveLocalCityGmlSrs(string fileSrs, SpatialContext2D spatialContext) { - var normalizedFileSrs = RhinoSpatialContextTools.NormalizeSrsKey(fileSrs); + var normalizedFileSrs = RhinoSpatialContextTools.NormalizeSrsKey(SpatialReferenceTransform.NormalizeSrs(fileSrs)); return string.IsNullOrWhiteSpace(normalizedFileSrs) ? RhinoSpatialContextTools.NormalizeSrsKey(spatialContext.ResolvedSrs) @@ -847,10 +916,17 @@ private static List LoadLocalCityGmlCandidates( skippedByBoundsCount = 0; var candidates = new List(); - foreach (var sourceFile in EnumerateLocalSourceFiles(requestData.Source, sourceKind)) + foreach (var sourceFile in (requestData.LocalSourcePaths ?? new[] { requestData.Source }) + .SelectMany(source => EnumerateLocalSourceFiles(source, sourceKind))) { scannedFileCount++; + if (requestData.LocalSourcePaths is not null) + { + using var documentStream = sourceFile.OpenRead(); + if (!Lod2GmlReader.IsCityGmlDocument(documentStream)) continue; + } + CityGmlSourceMetadata sourceMetadata; using (var sourceStream = sourceFile.OpenRead()) { @@ -858,6 +934,10 @@ private static List LoadLocalCityGmlCandidates( } var sourceSrs = ResolveLocalCityGmlSrs(sourceMetadata.SrsName, requestData.SpatialContext); + if (requestData.LocalSourcePaths is not null && + (string.IsNullOrWhiteSpace(sourceMetadata.SrsName) || + !SpatialReferenceTransform.TryTransformXY(sourceSrs, requestData.SpatialContext.ResolvedSrs, 0, 0, out _, out _))) + throw new InvalidOperationException($"Downloaded ATOM file '{sourceFile.DisplayName}' has missing or unsupported source CRS metadata. No approximate placement was used."); var sourceBoundingBox = sourceMetadata.BoundingBox; if (sourceBoundingBox is not null && @@ -1281,7 +1361,7 @@ private void NormalizeComponentLayout() Params.Input[0], "LoD2 Source", "LoD2 Source", - "LoD2 source URL, local CityGML/GML/XML/CityJSON file, folder, or ZIP archive. If left empty, RhinoSpatial will try to inherit a WFS URL from the connected Layer input."); + "LoD2 WFS or ATOM service URL, local CityGML/GML/XML/CityJSON file, folder, or ZIP archive. If left empty, RhinoSpatial will try to inherit a WFS URL from the connected Layer input."); Params.Input[0].Optional = true; } @@ -1291,7 +1371,7 @@ private void NormalizeComponentLayout() Params.Input[1], "Layer", "Layer", - "Optional LoD2 building layer name for WFS sources, or local layer label for file/folder/ZIP sources."); + "Optional LoD2 building layer name for WFS sources, or output label for ATOM/local sources."); Params.Input[1].Optional = true; } @@ -1301,7 +1381,7 @@ private void NormalizeComponentLayout() Params.Input[2], "Spatial Context", "Spatial Context", - "Shared RhinoSpatial spatial context. LoD2 requests use EPSG:7423/4326 internally, so any Spatial Context created from the map helper will align correctly."); + "Shared RhinoSpatial study area used to select, filter and align LoD2 buildings."); } while (Params.Input.Count > 3) diff --git a/RhinoSpatial/RhinoSpatial.csproj b/RhinoSpatial/RhinoSpatial.csproj index 7c4fb99..a134b05 100644 --- a/RhinoSpatial/RhinoSpatial.csproj +++ b/RhinoSpatial/RhinoSpatial.csproj @@ -1,4 +1,4 @@ - + net7.0 true @@ -8,7 +8,7 @@ - 0.3.3 + 0.4.0 RhinoSpatial RhinoSpatial RhinoSpatial diff --git a/RhinoSpatial/RhinoSpatialInfo.cs b/RhinoSpatial/RhinoSpatialInfo.cs index 436ea28..8c539c4 100644 --- a/RhinoSpatial/RhinoSpatialInfo.cs +++ b/RhinoSpatial/RhinoSpatialInfo.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Drawing; using Grasshopper.Kernel; @@ -18,6 +18,6 @@ public class RhinoSpatialInfo : GH_AssemblyInfo public override string AuthorContact => "https://pascalnun.eu/tools/rhinospatial/"; - public override string AssemblyVersion => GetType().Assembly.GetName().Version?.ToString() ?? "0.3.3"; + public override string AssemblyVersion => GetType().Assembly.GetName().Version?.ToString(3) ?? "0.4.0"; } } diff --git a/RhinoSpatial/SpatialTerrainCache.cs b/RhinoSpatial/SpatialTerrainCache.cs index 47cfeb9..4398ddd 100644 --- a/RhinoSpatial/SpatialTerrainCache.cs +++ b/RhinoSpatial/SpatialTerrainCache.cs @@ -115,7 +115,7 @@ private static bool TrySampleRaster(Entry entry, double x, double y, out double } var elevation = raster.Elevations[index]; - if (raster.NoDataValue.HasValue && Math.Abs(elevation - raster.NoDataValue.Value) < 1e-3) + if (TerrainMeshBuilder.IsNoData(raster, elevation)) { return null; } diff --git a/docs/COMPONENT_REFERENCE.md b/docs/COMPONENT_REFERENCE.md index 9470ac4..ff082ba 100644 --- a/docs/COMPONENT_REFERENCE.md +++ b/docs/COMPONENT_REFERENCE.md @@ -150,6 +150,8 @@ For local vector files, connect the file path directly to `WFS URL`. `Layer` can RhinoSpatial prefers GeoJSON where available and falls back to GML where needed. Local GeoJSON defaults to `EPSG:4326` unless it contains a readable older named `crs` member. +GeoJSON features may have `properties: null`. Empty coordinate arrays produce no geometry; they never create a point at the origin. Older named CRS identifiers support EPSG codes in short, URN, and OGC URL forms, including identifiers with a separate version segment. + The compatibility-oriented WFS path retains established EPSG:4326 XY behavior and handles confirmed EPSG:4258 latitude/longitude services explicitly. Provider-specific axis order, geometry encodings, and response formats can still require compatibility work. ## Load WMS: maps and imagery @@ -227,6 +229,12 @@ Outputs are the aligned `Terrain` mesh list and a `Status` message. `Coverage` identifies a WCS coverage where required. For local text grids it can also provide an EPSG override such as `EPSG:25832`; otherwise RhinoSpatial uses the current context SRS. +Terrain coordinates are transformed from the source SRS into the shared context before localization, including when the source uses a different projected CRS. Unsupported transformations and non-overlapping WCS coverage return explicit errors. Missing elevation samples leave holes in the surface, including during downsampling; RhinoSpatial does not replace them with zero-height terrain. A raster without connected usable cells returns an error. + +Esri ASCII Grid samples are placed at cell centers, while reference metadata describes the full cell footprint. Matching `xllcorner`/`yllcorner` and `xllcenter`/`yllcenter` headers are supported. `NODATA_value` is optional and defaults to `-9999`. Cell values may wrap across text lines; their total must match `ncols × nrows`. Invalid headers, truncated data, and extra cell values are rejected. These conventions follow the [Esri ASCII raster format](https://help.arcgis.com/en/arcgisdesktop/10.0/help/009t/009t0000000z000000.htm). + +XYZ/CSV terrain requires finite, evenly spaced X and Y coordinates. Sparse regular grids retain missing cells as NoData; irregularly spaced samples return an error instead of being stretched onto an artificial grid. + The built-in fallback is intended for small-area orientation and short request times. It fails quickly with a useful status when the area is too large, no usable elevation samples are available, or the request cannot complete promptly. Project work should prefer official or project-specific terrain. The WCS path supports ordinary single-part GeoTIFF responses and multipart GeoTIFF responses. Where capabilities omit a WGS84 extent, RhinoSpatial can use `DescribeCoverage` metadata to orient the context. @@ -238,21 +246,40 @@ Use `Load LoD2 Buildings` for official building massing, roof forms, and 3D urba One `LoD2 Source` input accepts: - a LoD2 WFS service URL +- an ATOM download-service URL with selectable CityGML ZIP tiles - local CityGML/GML/XML - local CityJSON - a folder containing supported building files - a ZIP archive containing supported building files -For WFS sources, select a building layer when needed. For local data, `Layer` can stay empty or be used as an output label. +For WFS sources, select a building layer when needed. For ATOM and local data, `Layer` can stay empty or be used as an output label. Public inputs: -- `LoD2 Source` — WFS URL, supported local file, folder, or ZIP path +- `LoD2 Source` — WFS or ATOM URL, supported local file, folder, or ZIP path - `Layer` — optional WFS layer name or local output label - `Spatial Context` — the shared selected area and placement context Outputs are `Buildings` as a tree grouped by layer and building, plus `Status`. +### Automatic ATOM downloads + +Connect the service URL and a `Spatial Context`; no tile naming, manual download or extraction is required. For example, Berlin's official LoD2 service is `https://gdi.berlin.de/data/a_lod2/atom/`. RhinoSpatial follows linked dataset feeds and pagination, selects intersecting tiles, downloads those ZIPs, and uses the existing CityGML filtering and Brep conversion path. Existing inputs, outputs and component identity are retained. + +Tile selection supports GeoRSS Simple boxes/polygons and GeoRSS GML envelopes, including native projected bounds. GeoRSS Simple coordinates are latitude/longitude independently of a dataset's CRS category. GML uses its declared CRS and authority axis order; an omitted CRS defaults to WGS84 latitude/longitude. Relative links and `xml:base` are resolved against the response URL. + +Some feeds, including Berlin's, publish a single dataset extent and many download links. These require additional tile information. The supported grid convention is a declared square grid size, an EPSG:25832 or EPSG:25833 category, an enclosing geographic extent, and filenames ending in `_EEE_NNNN` with southwest UTM coordinates in kilometres. Berlin explicitly links a `1x1 Km` tile index. This rule depends on metadata and the supported naming convention, not the service hostname. Arbitrary numeric filenames or whole-dataset bounds are never sufficient on their own. A feed with missing, ambiguous or unsupported spatial metadata returns a limitation before downloading building archives. Start from the service feed so its enclosing extent remains available. + +ATOM currently imports ZIP/GMZ containers holding CityGML (`.gml`, `.citygml`, `.xml`). Other archive formats and remote CityJSON downloads return an explicit unsupported-format error. Local CityJSON/file/folder/ZIP workflows remain supported. Downloaded CityGML must declare a source CRS that can transform into the context; no guessed CRS or approximate bounding-box placement is used. Standard AdV names such as `urn:adv:crs:ETRS89_UTM33*DE_DHHN2016_NH` resolve to UTM XY; published heights are retained without a vertical-datum conversion. + +Use a building dataset, not a terrain feed: Berlin's `/data/a_lod2/atom/` supplies LoD2 buildings, while `/data/dgm1/atom/` supplies CSV terrain grids. Explicit CSV/raster download MIME types are rejected before grid selection with a source-format explanation. `Load Terrain` supports local grids and WCS, but does not yet read ATOM feeds. + +Completed ZIPs persist in the user's local application-data folder under `RhinoSpatial/Lod2AtomCache`. Repeated solutions reuse them, including after restarting Rhino. The cache key includes the download URL and feed entry's `updated` value. With no revision value, archives refresh after one day. Cached length and SHA-256 are checked before reuse. Downloads are written to temporary files, checked for complete transfer and readable CityGML, and promoted only on success. Failures can be retried; successfully completed tiles remain cached. Delete this cache directory while RhinoSpatial is idle to reclaim space or force fresh downloads. Feed metadata is refreshed after five minutes within the running process. + +The component displays tile/download progress while working. `Status` reports selected tile filenames, downloaded and cached counts, cache location, parsed buildings, source CRS and conversion diagnostics. A failed required download or CityGML parse returns an error rather than presenting a partial area as complete. + +Limits are 64 feeds, 8 subfeed levels, 50,000 download entries and 32 selected tiles per solution; 512 MiB compressed per archive, 256 MiB per expanded file and 1 GiB total expanded per archive. Feed requests time out after 30 seconds, discovery after two minutes, individual downloads after three minutes and the download batch after five minutes. Reduce the study area or use a more specific spatially indexed feed if a limit is reached. ATOM metadata is not yet supported by `Spatial Context → Reference Source`; define the area with the map or use an existing supported reference. + ### Filtering and performance WFS mode requests a small buffered area to reduce provider BBOX edge misses, then keeps buildings that intersect the actual Spatial Context. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b0262a2..58046ec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,7 +25,7 @@ The guiding product idea remains: ## Current baseline -The current alpha line already provides: +The current release already provides: - one shared `Spatial Context` with saved area selection; - optional map orientation from WFS, WMS, WCS, and supported local sources; @@ -34,7 +34,7 @@ The current alpha line already provides: - vector loading from WFS, OGC API Features, Shapefile, and GeoJSON; - WMS imagery with an ordered orientation fallback; - terrain from WCS, GeoTIFF DEM, Esri ASCII Grid, XYZ/CSV grids, and a bounded global fallback; -- LoD2 buildings from WFS, CityGML/GML/XML, CityJSON, folders, and ZIP archives; +- LoD2 buildings from WFS, ATOM download feeds, CityGML/GML/XML, CityJSON, folders, and ZIP archives; - GeoTIFF raster placement; - curated OSM buildings, roads, water, green areas, and rail; - bounded Google Photorealistic 3D Tiles reference viewing; @@ -64,6 +64,7 @@ The most useful next improvements should come from real project use rather than ### LoD2 reliability +- validate the new ATOM tile-download/cache workflow in Rhino, including Berlin and other spatially indexed INSPIRE feeds; - distinguish provider coverage gaps from filtering or conversion failures; - retain the small internal WFS request buffer for BBOX edge cases; - keep folder and ZIP loading bounded by file/building metadata where available; diff --git a/docs/TEST_SOURCES.md b/docs/TEST_SOURCES.md index e24047b..d0af1e0 100644 --- a/docs/TEST_SOURCES.md +++ b/docs/TEST_SOURCES.md @@ -34,6 +34,46 @@ Use these together with: `dotnet run --project RhinoSpatial.Sandbox.csproj -- reference-source-check` - WFS/WMS request-axis check: `dotnet run --project RhinoSpatial.Sandbox.csproj -- ogc-request-check` +- Local GeoJSON and terrain-reader checks: + `dotnet run --project RhinoSpatial.Sandbox.csproj -- source-reader-check` +- Terrain placement, NoData, and downsampling checks: + `dotnet run --project RhinoSpatial.Sandbox.csproj -- terrain-mesh-check` +- ATOM metadata, tile selection, download validation and persistent-cache checks: + `dotnet run --project RhinoSpatial.Sandbox.csproj -- lod2-atom-check` +- ATOM discovery without archive downloads: + `dotnet run --project RhinoSpatial.Sandbox.csproj -- lod2atom ` + Add `download` to download/reuse selected archives and parse buildings through the existing CityGML reader. + +## Deterministic correctness regressions + +The source-reader checks generate temporary, non-private fixtures for ASCII corner/center origins, reference extents, optional NoData, wrapped/truncated/excess cell values, invalid headers, sparse and irregular XYZ grids, non-finite positions, GeoJSON null attributes, empty geometries, malformed points, and versioned EPSG names. They cover format behavior documented by [Esri](https://help.arcgis.com/en/arcgisdesktop/10.0/help/009t/009t0000000z000000.htm) and [GeoJSON RFC 7946](https://www.rfc-editor.org/rfc/rfc7946.html). + +The terrain-mesh checks exercise the same Core builder used by the Grasshopper component: projected and geographic source transforms, localization, absolute elevations, NoData holes (including holes between retained downsampled vertices), unsupported CRS errors, and preservation of raster edges. These do not validate Rhino materials, viewport rendering, or saved Grasshopper definitions; use the manual checklist for those surfaces. + +The ATOM checks use an in-process HTTP fixture and temporary ZIPs/cache directories, without external network requests. They cover subfeeds/pagination/cycles, relative URLs and `xml:base`, GeoRSS and GML axis order, native/projected tile selection, explicitly declared kilometre grids, missing spatial metadata, unsupported CRS/AdV aliases, WFS routing, limits/timeouts, archive parsing, concurrent cache writers, revision changes, corrupt/truncated downloads, invalid ZIP/CityGML and cancellation. Parsing follows [Atom RFC 4287](https://datatracker.ietf.org/doc/html/rfc4287) and the supported spatial subset of the [OGC GeoRSS standard](https://docs.ogc.org/cs/17-002r1/17-002r1.html). AdV horizontal naming is documented in the [official reference-system overview](https://www.ldbv.bayern.de/mam/ldbv/dateien/referenzsysteme.pdf). Source heights are preserved; these aliases do not convert vertical datums. + +### Berlin ATOM integration validation (2026-11-01) + +The [official service feed](https://gdi.berlin.de/data/a_lod2/atom/) links one dataset feed containing 925 ZIP links (`rel="section"`, `application/x-gmz`). There are no per-ZIP GeoRSS extents. The service entry supplies the enclosing geographic polygon and EPSG:25833 category; the dataset links a [1 km UTM tile index](https://gdi.berlin.de/data/a_lod2/atom/1X1_EPSG_25833.pdf). The index was inspected to verify the supported southwest-corner kilometre naming convention. The implementation uses this declared-grid pattern without a Berlin hostname special case, and refuses missing/ambiguous metadata. + +Verified console workflows: + +```bash +# Museum Island: selects only LoD2_391_5820.zip (6,731,543 compressed bytes). +dotnet run --project RhinoSpatial.Sandbox.csproj -- lod2atom 'https://gdi.berlin.de/data/a_lod2/atom/' EPSG:25833 '391300,5820100,391700,5820500' download + +# Crosses the 5820000 tile boundary: selects LoD2_391_5819.zip and LoD2_391_5820.zip. +dotnet run --project RhinoSpatial.Sandbox.csproj -- lod2atom 'https://gdi.berlin.de/data/a_lod2/atom/' EPSG:25833 '391300,5819900,391700,5820200' download +``` + +Museum Island parsed 133 buildings / 8,642 surfaces before the component's additional surface filter. The boundary request parsed 5 buildings / 740 surfaces from the southern tile and 29 / 2,695 from the northern tile. Repeated runs reused persisted downloads. CityGML declares `urn:adv:crs:ETRS89_UTM33*DE_DHHN2016_NH`; its XY resolves explicitly to EPSG:25833 while its height reference remains unchanged. + +Bounded comparison requests for the same Museum Island EPSG:25833 bounds also returned 100 building features (explicit limit) from Berlin ALKIS WFS and a 512 × 512 PNG (90,597 bytes) from ALKIS building WMS: + +- WFS: `https://gdi.berlin.de/services/wfs/alkis?SERVICE=WFS&REQUEST=GetCapabilities`, layer `alkis:gebaeudeflaechen` +- WMS: `https://gdi.berlin.de/services/wms/alkis_gebaeude?SERVICE=WMS&REQUEST=GetCapabilities`, layer `gebaeude` + +These confirm bounded source loading and compatible native coordinate metadata. They do **not** prove visual alignment, Brep quality, progress display or saved-definition compatibility in Rhino. Complete the ATOM entries in `examples/VALIDATION.md` before release. ATOM reference-map preloading remains deferred; use the map helper or another supported reference source. ## Current Tested Sources @@ -115,6 +155,7 @@ Use these together with: | ID | Region | Source | URL | Current Use | | --- | --- | --- | --- | --- | +| `lod2-berlin-atom` | Germany / Berlin | Official LoD2 ATOM download service | `https://gdi.berlin.de/data/a_lod2/atom/` | Live single- and two-tile discovery, ZIP download, persistent cache reuse and bounded CityGML parsing verified. Declared 1 km EPSG:25833 grid and AdV compound CRS. Visual Rhino validation pending; see the integration notes above. | | `lod2-hessen-buildings` | Germany / Hessen | Hessen LoD2 WFS | `https://www.geoportal.hessen.de/mapbender/php/wfs.php?FEATURETYPE_ID=5589&INSPIRE=1&REQUEST=GetCapabilities&SERVICE=WFS&VERSION=2.0.0` | Existing LoD2 WFS baseline. Good for terrain/building alignment. | | `lod2-sachsen-anhalt` | Germany / Sachsen-Anhalt | Sachsen-Anhalt LoD2 WFS | `https://www.geodatenportal.sachsen-anhalt.de/wss/service/ST_LVermGeo_LoD2_WFS/guest?request=GetCapabilities&service=WFS&version=2.0.0` | Bounded `ALKIS_LOD2_BU:BU.Building` request parsed successfully. Validates EPSG:4258 WFS axis order and inherited `srsDimension="3"` on enclosing GML geometry. | | `citygml-ogc-building-lod2` | Format sample | OGC CityGML 2.0 LoD2 building sample | `https://schemas.opengis.net/citygml/examples/2.0/building/Building_LOD2-EPSG25832.gml` | Downloaded and parsed successfully with `lod2file`; 1 building / 7 surfaces. | diff --git a/examples/README.md b/examples/README.md index 00caef1..72f91f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,7 +28,7 @@ A local raster and terrain workflow. The GeoTIFF input is intentionally a placeh [Download `03-lod2-buildings.gh`](https://github.com/PascalNun/RhinoSpatial/raw/main/examples/gh/03-lod2-buildings.gh) -Official 3D building and roof context from a LoD2 WFS service or supported CityGML/CityJSON source. +Official 3D building and roof context from a LoD2 WFS service, ATOM download service, or supported local CityGML/CityJSON source. For automatic Berlin tiles, connect `https://gdi.berlin.de/data/a_lod2/atom/` to `LoD2 Source` and select a small Berlin area with `Spatial Context`; leave `Layer` empty. ### OSM context @@ -44,7 +44,7 @@ Optional Google Photorealistic 3D Tiles visual context. The example does not con ## How to use the examples -1. Install the current RhinoSpatial alpha through Package Manager and enable **Include pre-releases**. +1. Install the current RhinoSpatial release through Package Manager. 2. Download and open the chosen `.gh` definition. 3. Replace example URLs, layer names, local files, or API-key placeholders with sources suitable for your project. 4. Open `Spatial Context` and select a reasonably small study area. diff --git a/examples/VALIDATION.md b/examples/VALIDATION.md index f5e8193..ca185ed 100644 --- a/examples/VALIDATION.md +++ b/examples/VALIDATION.md @@ -12,6 +12,7 @@ It is intentionally small and practical. The point is to confirm that the core d - sandbox probes from `docs/TEST_SOURCES.md` still run for at least one bounded WFS GetFeature, WMS GetMap, WCS GetCoverage, one CityGML file, one CityJSON file, one Shapefile, and one GeoTIFF file; capabilities-only checks do not count as full source validation - the sandbox `tiles-selection-check` passes before manual Google 3D Tiles validation - the sandbox `ogc-request-check` and `reference-source-check` pass before service and local-reference validation +- the sandbox `source-reader-check` and `terrain-mesh-check` pass before local vector and terrain validation - component grouping still reads as `Context / Layers / Sources / Viewers` - icons load correctly for all components - `Spatial Context` still acts as the shared starting point @@ -56,6 +57,15 @@ It is intentionally small and practical. The point is to confirm that the core d ## LoD2 Buildings +- run `lod2-atom-check` before live ATOM validation +- connect `https://gdi.berlin.de/data/a_lod2/atom/` directly to `LoD2 Source`, leave `Layer` empty, and select Museum Island in an EPSG:25833 Spatial Context +- confirm selected tiles, cache/download counts and building/conversion diagnostics appear in `Status`, and tile progress appears on the component +- on macOS, trigger an ATOM load both normally and from the Grasshopper recompute menu; confirm progress callbacks do not deadlock the UI while Grasshopper waits for the worker task +- repeat a solution and restart Rhino; confirm completed archives are reused +- select an area crossing northing 5820000 and confirm both intersecting tiles load +- compare ATOM building geometry against Berlin ALKIS building WFS and WMS using one context; repeat with a different supported context CRS and absolute coordinates +- confirm missing tile metadata, unsupported archives, unavailable services and interrupted downloads return clear errors without a misleading partial model +- confirm existing saved LoD2 definitions still open with the same component GUID and three inputs/two outputs - connect a LoD2 WFS source - load buildings through `Load LoD2 Buildings` - test a local CityGML/GML/XML building file through the same `LoD2 Source` input when available @@ -83,6 +93,10 @@ It is intentionally small and practical. The point is to confirm that the core d - confirm ordinary single-part GeoTIFF responses (NRW/PDOK) and multipart GeoTIFF responses (USGS 3DEP) both produce readable terrain rasters - use a WCS whose capabilities omit WGS84 bounds and confirm `DescribeCoverage` native bounds still preload the Spatial Context map - connect a local `.tif` / `.tiff` DEM path to `Load Terrain` and confirm it produces an aligned terrain mesh or a clear non-overlap warning +- compare equivalent corner-origin and center-origin ASCII grids; their terrain vertices should coincide at cell centers and the reference map should fit the full raster footprint +- combine terrain in EPSG:25832 or EPSG:28992 with a different supported Spatial Context CRS and confirm imagery/vector alignment in both localized and absolute modes +- load terrain containing NoData holes and confirm no artificial flat patches or invalid OSM building heights appear; repeat at a resolution requiring downsampling and confirm the last row and column remain included +- confirm incomplete ASCII grids, irregular XYZ spacing, and terrain with no usable surface return clear status messages ## GeoTIFF diff --git a/examples/sources.json b/examples/sources.json index cc1a6f3..51d6bff 100644 --- a/examples/sources.json +++ b/examples/sources.json @@ -14,6 +14,20 @@ ] }, "sources": [ + { + "id": "lod2-berlin-atom", + "type": "LoD2 ATOM", + "purpose": "Automatic official Berlin CityGML tile selection and cached downloads", + "url": "https://gdi.berlin.de/data/a_lod2/atom/", + "notes": [ + "Connect directly to Load LoD2 Buildings; leave Layer empty and select a small Berlin Spatial Context", + "Museum Island EPSG:25833 bounds 391300,5820100,391700,5820500 selected one tile and parsed 133 buildings", + "Boundary bounds 391300,5819900,391700,5820200 selected two tiles", + "Tile selection uses the published 1x1 Km grid, EPSG:25833 category, enclosing extent and UTM kilometre filenames", + "Archives contain AdV ETRS89_UTM33 with DHHN2016 heights; source heights are retained", + "Downloads and parsing verified; visual Rhino alignment remains a manual validation step" + ] + }, { "id": "terrain-global-skadi-fallback", "type": "Terrain", diff --git a/package/food4rhino/Food4Rhino-listing.md b/package/food4rhino/Food4Rhino-listing.md index 2e73205..730cd1d 100644 --- a/package/food4rhino/Food4Rhino-listing.md +++ b/package/food4rhino/Food4Rhino-listing.md @@ -28,7 +28,7 @@ RhinoSpatial can work with: - local Shapefile and GeoJSON project data - maps, orthophotos, and imagery from WMS or GeoTIFF - terrain from WCS, GeoTIFF DEM, Esri ASCII Grid, or XYZ/CSV grids -- LoD2 buildings from WFS, CityGML, CityJSON, folders, or ZIP archives +- LoD2 buildings from WFS, ATOM download feeds, CityGML, CityJSON, folders, or ZIP archives - lightweight OpenStreetMap context for buildings, roads, water, green areas, and rail - Google Photorealistic 3D Tiles as an optional visual reference layer @@ -38,9 +38,9 @@ Public fallback sources make it easier to get oriented and try the workflow, whi The Google 3D Tiles component is an optional reference viewer. It requires your own Google Maps Platform API key and billing setup. It is intended for visual context—not as an editable, bakeable, exportable, or offline project-data source. -## Early Stage Note +## Current Release -RhinoSpatial is still in active alpha development. Version `0.3.3-alpha` improves layer alignment, Google 3D Tiles behavior, Spatial Context reference sources, and compatibility across WFS, WMS, WCS, LoD2, and local files. +Version **0.4.0** adds automatic LoD2 tile imports from supported ATOM feeds, including Berlin, with persistent download caching. It also recognizes AdV UTM32/UTM33 CRS names and corrects terrain placement, NoData handling, and local vector parsing. The macOS progress-update freeze encountered during testing is fixed. The complete planned source set is now present, but individual providers, coordinate systems, geometry types, and file metadata can still expose edge cases. @@ -78,11 +78,11 @@ Food4Rhino listing: Rhino Package Manager: -- Open Rhino 8, run `PackageManager`, enable **Include pre-releases**, search for `RhinoSpatial`, and install the package. +- Open Rhino 8, run `PackageManager`, search for `RhinoSpatial`, and install the package. Current release package: -- `RhinoSpatial-0.3.3-alpha.zip` +- `RhinoSpatial-0.4.0.zip` ## Repository diff --git a/package/food4rhino/INSTALL.md b/package/food4rhino/INSTALL.md index 1413eb4..42e3a72 100644 --- a/package/food4rhino/INSTALL.md +++ b/package/food4rhino/INSTALL.md @@ -1,6 +1,6 @@ # RhinoSpatial Installation -This package is currently an alpha release of RhinoSpatial for Rhino 8. +This package contains RhinoSpatial 0.4.0 for Rhino 8 on macOS and Windows. ## Package Manager Installation @@ -8,10 +8,9 @@ The easiest way to install RhinoSpatial is through Rhino's Package Manager. 1. Open Rhino 8. 2. Run `PackageManager`. -3. Enable **Include pre-releases** while RhinoSpatial remains in alpha. -4. Search for `RhinoSpatial`. -5. Install the package. -6. Restart Rhino / Grasshopper if needed. +3. Search for `RhinoSpatial`. +4. Install the package. +5. Restart Rhino / Grasshopper if needed. You can also open the [RhinoSpatial page on Food4Rhino](https://www.food4rhino.com/en/app/rhinospatial?lang=en). The [RhinoSpatial website](https://pascalnun.eu/tools/rhinospatial/) provides the user guide and example workflows. @@ -70,11 +69,11 @@ Good first components to try are: ## Notes -- RhinoSpatial is currently still in an alpha stage. +- Version 0.4.0 installs without enabling pre-releases. - The plugin has been exercised against a growing international set of public services and local formats, but provider-specific services and unusual project data can still expose edge cases. - Leave imagery, terrain, or OSM source inputs empty only when you want RhinoSpatial's broad fallback behavior for quick context. The terrain fallback is intentionally limited to small study areas and short request times. For project work, prefer your own official or project-specific data sources. - `Load WFS` accepts WFS URLs, OGC API Features URLs, local `.shp` files, and local `.geojson` / GeoJSON `.json` files for vector context. -- `Load LoD2 Buildings` accepts a LoD2 WFS URL, local CityGML/GML/XML/CityJSON file, folder, or ZIP archive through one `LoD2 Source` input. Large local files may still take time because they have to be inspected before out-of-context buildings can be skipped. +- `Load LoD2 Buildings` accepts a LoD2 WFS or ATOM URL, local CityGML/GML/XML/CityJSON file, folder, or ZIP archive through one `LoD2 Source` input. Large local files may still take time because they have to be inspected before out-of-context buildings can be skipped. - The Google 3D Tiles component is a viewer/reference workflow that requires the user's own Google Maps API key and should not be treated as an editable project data source, cache, bake, or export workflow. - Behavior may still vary depending on the provider, geometry type, SRS, version, response format, API access, or local file metadata. - Release packages also include a small number of third-party libraries. See `THIRD-PARTY-NOTICES.md` for bundled dependency notices. diff --git a/package/food4rhino/PACKAGE_README.md b/package/food4rhino/PACKAGE_README.md index 45294fd..a01130d 100644 --- a/package/food4rhino/PACKAGE_README.md +++ b/package/food4rhino/PACKAGE_README.md @@ -10,12 +10,15 @@ The recommended installation method is Rhino 8's Package Manager: 1. Open Rhino 8. 2. Run `PackageManager`. -3. Enable **Include pre-releases** while RhinoSpatial remains in alpha. -4. Search for `RhinoSpatial`. -5. Install the package and restart Rhino or Grasshopper if requested. +3. Search for `RhinoSpatial`. +4. Install the package and restart Rhino or Grasshopper if requested. For manual installation from this package, read [INSTALL.md](INSTALL.md). +## New in 0.4.0 + +`Load LoD2 Buildings` now accepts supported ATOM download services, selects the CityGML tiles for your study area and caches completed downloads. AdV UTM CRS names, terrain placement and NoData handling, and local vector parsing are improved. See [CHANGELOG.md](CHANGELOG.md). + ## First workflow 1. Open Grasshopper and place `Spatial Context`. @@ -43,6 +46,6 @@ By default, RhinoSpatial localizes data near Rhino's origin so aligned geometry - [Releases](https://github.com/PascalNun/RhinoSpatial/releases) - [Issues and support](https://github.com/PascalNun/RhinoSpatial/issues) -RhinoSpatial is in active alpha development. Provider-specific services, coordinate systems, geometry types, API access, and local-file metadata can still expose edge cases. For project work, prefer official or project-specific data sources. +RhinoSpatial 0.4.0 is the recommended public release. Provider-specific services, coordinate systems, geometry types, API access, and local-file metadata can still expose edge cases. For project work, prefer official or project-specific data sources. RhinoSpatial is released under the [MIT License](https://github.com/PascalNun/RhinoSpatial/blob/main/LICENSE). Bundled dependency licenses are documented in the [Third-Party Notices](https://github.com/PascalNun/RhinoSpatial/blob/main/THIRD-PARTY-NOTICES.md). Both files are also included beside this README in the release package. diff --git a/package/yak/manifest.yml b/package/yak/manifest.yml index e4f71d6..8d59686 100644 --- a/package/yak/manifest.yml +++ b/package/yak/manifest.yml @@ -1,6 +1,6 @@ --- name: RhinoSpatial -version: 0.3.3.0 +version: 0.4.0 authors: - Pascal Nünninghoff description: Bring maps, terrain, buildings, imagery, and geodata into one aligned Rhino and Grasshopper site context. @@ -13,6 +13,9 @@ keywords: - wms - terrain - geotiff + - citygml + - lod2 + - atom - osm - 3d-tiles - gis diff --git a/sandbox/Lod2AtomChecks.cs b/sandbox/Lod2AtomChecks.cs new file mode 100644 index 0000000..7aa888d --- /dev/null +++ b/sandbox/Lod2AtomChecks.cs @@ -0,0 +1,334 @@ +using System.IO.Compression; +using System.Net; +using System.Text; +using System.Xml.Linq; +using RhinoSpatial.Core; + +namespace RhinoSpatial.Sandbox; + +internal static class Lod2AtomChecks +{ + private const string Header = ""; + private const string Building = """ + + 391000 5820000 0392000 5821000 100 + 391100 5820100 20 391200 5820100 20 391200 5820200 20 391100 5820200 20 391100 5820100 20 + + """; + + internal static SpatialContext2D Context(string srs, BoundingBox2D bounds) => + new(srs, bounds, bounds, null, new Coordinate2D(bounds.MinX, bounds.MinY), false, + new Dictionary { [srs] = bounds }); + + private static string TileEntry(string name, string spatial, string updated = "2026-01-01T00:00:00Z") => + $"{name}{updated}{spatial}"; + + public static async Task RunAsync() + { + var directory = Path.Combine(Path.GetTempPath(), "rhinospatial-atom-check-" + Guid.NewGuid().ToString("N")); + var checks = 0; + async Task Check(string label, Func action) + { + await action(); + checks++; + Console.WriteLine("PASS " + label); + } + try + { + var handler = new FixtureHandler(); + using var http = new HttpClient(handler); + var client = new Lod2AtomClient(http, directory); + var context = Context("EPSG:25833", new BoundingBox2D(391050, 5820050, 391250, 5820250)); + const string native = "391000 5820000392000 5821000"; + const string distant = "40 1 41 2"; + handler.Xml("https://fixture.test/root", Header + ""); + handler.Xml("https://fixture.test/dataset.atom", Header + TileEntry("selected", native) + TileEntry("distant", distant) + ""); + handler.Xml("https://fixture.test/page2.atom", Header + TileEntry("edge", "391250 5820000392000 5821000") + ""); + Lod2AtomSelection selection = null!; + await Check("subfeeds, pagination, cycles, native CRS and edge-only exclusion", async () => + { + selection = (await client.TrySelectAsync("https://fixture.test/root", context))!; + Require(selection.FeedCount == 3 && selection.DownloadCount == 3 && selection.Tiles.Count == 1, "Incorrect traversal/selection."); + Require(handler.Count("https://fixture.test/selected.zip") == 0, "Discovery downloaded building data."); + }); + await Check("GeoRSS polygon lat/lon, independent of dataset CRS category", () => + { + var feed = Lod2AtomFeedReader.Read(XDocument.Parse(Header + "52 13 52 14 53 14 53 13 52 13"), new Uri("https://fixture.test/root")); + Require(feed.Downloads[0].Extent == new AtomSpatialExtent("EPSG:4326", new BoundingBox2D(13, 52, 14, 53)), "GeoRSS axis order or CRS changed."); + return Task.CompletedTask; + }); + await Check("xml:base and relative URL resolution", () => + { + var feed = Lod2AtomFeedReader.Read(XDocument.Parse(Header.Replace(">", " xml:base='../data/'>") + "52 13 53 14"), new Uri("https://fixture.test/service/root.atom")); + Require(feed.Downloads[0].Url.AbsoluteUri == "https://fixture.test/data/archives/tile.zip?format=gml&v=2", "Relative URL resolution failed."); + return Task.CompletedTask; + }); + await Check("authority GML geographic axis order and CRS84", () => + { + var node = XElement.Parse("52 1353 14"); + Require(Lod2AtomFeedReader.ReadExtent(node)!.Bounds == new BoundingBox2D(13, 52, 14, 53), "GML EPSG:4326 requires authority axis order."); + node.Descendants().Single(element => element.Name.LocalName == "Envelope").Attribute("srsName")!.Remove(); + Require(Lod2AtomFeedReader.ReadExtent(node)!.Bounds == new BoundingBox2D(13, 52, 14, 53), "GeoRSS GML defaults to latitude/longitude."); + node.Descendants().Single(element => element.Name.LocalName == "Envelope").SetAttributeValue("srsName", "urn:ogc:def:crs:OGC::CRS84"); + node.Descendants().Single(element => element.Name.LocalName == "lowerCorner").Value = "13 52"; + node.Descendants().Single(element => element.Name.LocalName == "upperCorner").Value = "14 53"; + Require(Lod2AtomFeedReader.ReadExtent(node)!.Bounds == new BoundingBox2D(13, 52, 14, 53), "CRS84 must use longitude/latitude."); + return Task.CompletedTask; + }); + await Check("declared UTM kilometre grid across two tile boundaries", async () => + { + handler.Xml("https://fixture.test/grid", Header + "52.3 13.0 52.7 13.8"); + var selected = await client.TrySelectAsync("https://fixture.test/grid", Context("EPSG:25833", new BoundingBox2D(391950, 5820100, 392050, 5820200))); + Require(selected!.Tiles.Count == 2, "Both intersecting tiles must be selected."); + Require(selected.Tiles[0].Extent!.Bounds == new BoundingBox2D(391000, 5820000, 392000, 5821000), "Grid origin is incorrect."); + }); + await Check("projected study area selects geographic tile bounds", async () => + { + handler.Xml("https://fixture.test/geographic", Header + TileEntry("geographic", "52.5 13.3 52.6 13.5") + ""); + Require((await client.TrySelectAsync("https://fixture.test/geographic", context))!.Tiles.Count == 1, "Projected to geographic selection failed."); + }); + await Check("multiple unindexed links cannot inherit whole-dataset bounds", async () => + { + handler.Xml("https://fixture.test/missing", Header + "52 13 53 14"); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/missing", context), "per-tile"); + }); + await Check("terrain download formats are diagnosed before grid selection", async () => + { + handler.Xml("https://fixture.test/terrain", Header + "52.3 13.0 52.7 13.8"); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/terrain", context), "not CityGML building data"); + Require(handler.Count("https://fixture.test/DGM1_416_5810.zip") == 0, "A terrain archive was downloaded by the building loader."); + }); + await Check("out-of-area subfeeds are not requested", async () => + { + handler.Xml("https://fixture.test/outside", Header + "" + distant + ""); + var empty = await client.TrySelectAsync("https://fixture.test/outside", context); + Require(empty!.Tiles.Count == 0 && handler.Count("https://fixture.test/unused.atom") == 0, "Out-of-area subfeed was fetched."); + }); + await Check("HTTP feed errors and timeouts remain actionable", async () => + { + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/unavailable.atom", context), "404"); + handler.Responses["https://fixture.test/timeout.atom"] = () => throw new TaskCanceledException(); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/timeout.atom", context), "Timed out"); + }); + await Check("traversal and selected-tile budgets reject oversized work", async () => + { + handler.Xml("https://fixture.test/oversized", Header + string.Concat(Enumerable.Range(0, 33).Select(i => TileEntry("budget" + i, native))) + ""); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/oversized", context), "limit is 32"); + for (var i = 0; i < 10; i++) + handler.Xml($"https://fixture.test/deep{i}.atom", Header + $""); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/deep0.atom", context), "traversal limit"); + }); + await Check("non-HTTP links are rejected", () => + { + try { Lod2AtomFeedReader.Read(XDocument.Parse(Header + TileEntry("file:///private/tile", native) + ""), new Uri("https://fixture.test/")); } + catch (InvalidOperationException ex) when (ex.Message.Contains("HTTP")) { return Task.CompletedTask; } + throw new Exception("Non-HTTP link accepted."); + }); + await Check("tile names alone are insufficient", async () => + { + handler.Xml("https://fixture.test/guessed", Header + ""); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/guessed", context), "per-tile"); + }); + await Check("unsupported CRS, malformed and nonfinite bounds fail", async () => + { + handler.Xml("https://fixture.test/unsupported", Header + TileEntry("u", native.Replace("25833", "99999")) + ""); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/unsupported", context), "transformation"); + handler.Xml("https://fixture.test/nonfinite", Header + TileEntry("u", "NaN 13 53 14") + ""); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/nonfinite", context), "bounds"); + handler.Xml("https://fixture.test/ambiguous", Header + TileEntry("u", native + distant) + ""); + await ThrowsAsync(() => client.TrySelectAsync("https://fixture.test/ambiguous", context), "ambiguous"); + }); + await Check("AdV compound CRS uses UTM XY without changing elevation", () => + { + Require(SpatialReferenceTransform.TryTransformXY("urn:adv:crs:ETRS89_UTM33*DE_DHHN2016_NH", "EPSG:25833", 391100, 5820100, out var x, out var y) && + x == 391100 && y == 5820100, "AdV UTM33 alias must preserve XY."); + Require(SpatialReferenceTransform.NormalizeSrs("urn:adv:crs:ETRS89_UTM32*DE_DHHN92_NH") == "EPSG:25832", "AdV UTM32 alias failed."); + Require(!SpatialReferenceTransform.TryTransformXY("urn:adv:crs:ETRS89_UTM33_WITH_ZONE", "EPSG:25833", 33391100, 5820100, out _, out _), "Unknown AdV variants must not be guessed."); + return Task.CompletedTask; + }); + await Check("non-ATOM WFS source preserves WFS routing", async () => + { + handler.Xml("https://fixture.test/wfs", ""); + Require(await client.TrySelectAsync("https://fixture.test/wfs", context) is null, "Non-ATOM source was captured."); + handler.Responses["https://fixture.test/wfs-error"] = () => new HttpResponseMessage(HttpStatusCode.BadRequest); + Require(await client.TrySelectAsync("https://fixture.test/wfs-error", context) is null, "WFS root requiring a query must retain its route."); + }); + + var zip = Zip(Building); + handler.Bytes("https://fixture.test/selected.zip", zip); + IReadOnlyList first = null!; + await Check("download, archive validation and existing CityGML pipeline", async () => + { + first = await client.DownloadAsync(selection); + Require(first.Count == 1 && !first[0].UsedCache, "First load should download."); + using var archive = ZipFile.OpenRead(first[0].FilePath); + using var stream = archive.Entries.Single().Open(); + using var reader = new StreamReader(stream); + var gml = await reader.ReadToEndAsync(); + Require(Lod2GmlReader.ReadBuildings(gml, "tile", context.RequestBoundingBox).Count == 1, "Existing CityGML parser failed."); + Require(Lod2GmlReader.ReadBuildings(gml, "tile", new BoundingBox2D(1, 1, 2, 2)).Count == 0, "Existing building filter failed."); + }); + await Check("persistent cache works with a new client without network", async () => + { + var cached = await new Lod2AtomClient(http, directory).DownloadAsync(selection); + Require(cached[0].UsedCache && handler.Count("https://fixture.test/selected.zip") == 1, "Repeated load downloaded again."); + }); + await Check("corrupt and incomplete cached files are redownloaded", async () => + { + await File.WriteAllBytesAsync(first[0].FilePath, new byte[zip.Length]); + Require(!(await client.DownloadAsync(selection))[0].UsedCache, "Same-size corrupt cache was accepted."); + await File.WriteAllBytesAsync(first[0].FilePath, new byte[] { 1, 2 }); + Require(!(await client.DownloadAsync(selection))[0].UsedCache, "Truncated cache was accepted."); + }); + await Check("feed revision invalidates the archive cache", async () => + { + var revised = selection with { Tiles = new[] { selection.Tiles[0] with { Revision = "new-revision" } } }; + Require(!(await client.DownloadAsync(revised))[0].UsedCache, "Changed feed revision used stale cache."); + }); + await Check("invalid ZIP and unavailable download leave no completed cache", async () => + { + var invalid = selection with { Tiles = new[] { selection.Tiles[0] with { Url = new Uri("https://fixture.test/invalid.zip") } } }; + handler.Bytes("https://fixture.test/invalid.zip", Encoding.UTF8.GetBytes("not a ZIP")); + await ThrowsAsync(() => client.DownloadAsync(invalid), "Could not load ATOM tile"); + handler.Bytes("https://fixture.test/invalid.zip", zip); + Require(!(await client.DownloadAsync(invalid))[0].UsedCache, "Failure poisoned the cache."); + var absent = selection with { Tiles = new[] { selection.Tiles[0] with { Url = new Uri("https://fixture.test/absent.zip") } } }; + await ThrowsAsync(() => client.DownloadAsync(absent), "404"); + Require(!Directory.EnumerateFiles(directory, "*.partial").Any(), "Partial download remains."); + }); + await Check("unsupported archive content fails clearly", async () => + { + var unsupported = selection with { Tiles = new[] { selection.Tiles[0] with { Url = new Uri("https://fixture.test/unsupported.zip") } } }; + handler.Bytes("https://fixture.test/unsupported.zip", Zip("", "readme.xml")); + await ThrowsAsync(() => client.DownloadAsync(unsupported), "expected a ZIP"); + }); + await Check("truncated CityGML is rejected before becoming a completed cache", async () => + { + var malformed = selection with { Tiles = new[] { selection.Tiles[0] with { Url = new Uri("https://fixture.test/malformed.zip") } } }; + handler.Bytes("https://fixture.test/malformed.zip", Zip(Building[..^30])); + await ThrowsAsync(() => client.DownloadAsync(malformed), "Could not load ATOM tile"); + handler.Bytes("https://fixture.test/malformed.zip", zip); + Require(!(await client.DownloadAsync(malformed))[0].UsedCache, "Malformed CityGML poisoned cache."); + }); + await Check("HTTP length mismatch rejected and partial removed", async () => + { + var mismatch = selection with { Tiles = new[] { selection.Tiles[0] with { Url = new Uri("https://fixture.test/short.zip") } } }; + handler.Responses["https://fixture.test/short.zip"] = () => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(zip) }; + response.Content.Headers.ContentLength = zip.Length + 10; + return response; + }; + await ThrowsAsync(() => client.DownloadAsync(mismatch), "incomplete"); + Require(!Directory.EnumerateFiles(directory, "*.partial").Any(), "Partial file remains."); + }); + await Check("cancellation does not publish an archive", async () => + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + try { await client.DownloadAsync(selection, cancellationToken: cancelled.Token); throw new Exception("Cancellation ignored."); } + catch (OperationCanceledException) { } + }); + await Check("cancellation during transfer removes partial files and releases locks", async () => + { + using var cancel = new CancellationTokenSource(); + var interrupted = selection with { Tiles = new[] { selection.Tiles[0] with { Url = new Uri("https://fixture.test/interrupted.zip") } } }; + handler.Responses["https://fixture.test/interrupted.zip"] = () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new CancelAfterReadStream(zip, cancel)) + }; + try { await client.DownloadAsync(interrupted, cancellationToken: cancel.Token); throw new Exception("Transfer was not cancelled."); } + catch (OperationCanceledException) { } + Require(!Directory.EnumerateFiles(directory, "*.partial").Any(), "Interrupted transfer left a partial file."); + handler.Bytes("https://fixture.test/interrupted.zip", zip); + Require(!(await client.DownloadAsync(interrupted))[0].UsedCache, "Interrupted transfer became a completed archive."); + }); + await Check("concurrent clients share a completed archive", async () => + { + var concurrent = selection with { Tiles = new[] { selection.Tiles[0] with { Revision = "concurrent" } } }; + var before = handler.Count("https://fixture.test/selected.zip"); + var results = await Task.WhenAll(client.DownloadAsync(concurrent), new Lod2AtomClient(http, directory).DownloadAsync(concurrent)); + Require(handler.Count("https://fixture.test/selected.zip") == before + 1, "Concurrent solve downloaded twice."); + Require(results.Count(result => result[0].UsedCache) == 1, "Concurrent writer did not reuse cache."); + }); + Console.WriteLine($"All {checks} ATOM checks passed."); + } + finally { if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } + } + + public static async Task ProbeAsync(string[] args) + { + if (args.Length < 4) throw new ArgumentException("Usage: lod2atom [download]"); + var bounds = args[3].Split(',').Select(value => double.Parse(value, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); + var context = Context(args[2], new BoundingBox2D(bounds[0], bounds[1], bounds[2], bounds[3])); + var client = new Lod2AtomClient(); + var selection = await client.TrySelectAsync(args[1], context) ?? throw new InvalidOperationException("Not an ATOM source."); + Console.WriteLine($"Feeds {selection.FeedCount}; indexed downloads {selection.DownloadCount}; selected tiles {selection.Tiles.Count}."); + foreach (var tile in selection.Tiles) Console.WriteLine($"Selected {tile.Url} ({tile.Extent})"); + if (args.Length < 5 || args[4] != "download") return; + var files = await client.DownloadAsync(selection, Console.WriteLine); + foreach (var file in files) + { + Console.WriteLine($"Cached={file.UsedCache}; path={file.FilePath}; size={new FileInfo(file.FilePath).Length}"); + using var archive = ZipFile.OpenRead(file.FilePath); + foreach (var entry in archive.Entries.Where(entry => new[] { ".gml", ".xml", ".citygml" }.Contains(Path.GetExtension(entry.FullName).ToLowerInvariant()))) + { + using var stream = entry.Open(); + var metadata = Lod2GmlReader.ReadSourceMetadata(stream); + var filter = Lod2AtomFeedReader.TransformBounds(new AtomSpatialExtent(context.ResolvedSrs, context.RequestBoundingBox), metadata.SrsName); + using var text = new StreamReader(entry.Open()); + var buildings = Lod2GmlReader.ReadBuildings(await text.ReadToEndAsync(), entry.FullName, filter); + Console.WriteLine($"{entry.FullName}: {metadata}; kept {buildings.Count} buildings / {buildings.Sum(b => b.Surfaces.Count)} surfaces."); + foreach (var point in buildings.SelectMany(b => b.Surfaces).SelectMany(surface => surface.OuterPoints).Take(1)) + { + Require(SpatialReferenceTransform.TryTransformXY(metadata.SrsName, context.ResolvedSrs, point.X, point.Y, out var x, out var y), "Building transform failed."); + Console.WriteLine($"First placed XY: {x - context.PlacementOrigin.X:0.###}, {y - context.PlacementOrigin.Y:0.###}; elevation {point.Z:0.###}."); + } + } + } + } + + private static byte[] Zip(string content, string name = "tile.gml") + { + using var bytes = new MemoryStream(); + using (var archive = new ZipArchive(bytes, ZipArchiveMode.Create, leaveOpen: true)) + using (var writer = new StreamWriter(archive.CreateEntry(name).Open())) writer.Write(content); + return bytes.ToArray(); + } + + private static void Require(bool condition, string message) { if (!condition) throw new Exception(message); } + + private static async Task ThrowsAsync(Func action, string contains) + { + try { await action(); } + catch (Exception ex) when (ex.Message.Contains(contains, StringComparison.OrdinalIgnoreCase)) { return; } + throw new Exception("Expected failure containing: " + contains); + } + + private sealed class FixtureHandler : HttpMessageHandler + { + public Dictionary> Responses { get; } = new(StringComparer.Ordinal); + private readonly System.Collections.Concurrent.ConcurrentDictionary _counts = new(); + public int Count(string url) => _counts.GetValueOrDefault(url); + public void Xml(string url, string xml) => Responses[url] = () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(xml, Encoding.UTF8, "application/xml") }; + public void Bytes(string url, byte[] bytes) => Responses[url] = () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.AbsoluteUri; + _counts.AddOrUpdate(url, 1, (_, count) => count + 1); + var response = Responses.TryGetValue(url, out var factory) ? factory() : new HttpResponseMessage(HttpStatusCode.NotFound); + response.RequestMessage = request; + return Task.FromResult(response); + } + } + + private sealed class CancelAfterReadStream(byte[] bytes, CancellationTokenSource cancel) : MemoryStream(bytes) + { + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var count = await base.ReadAsync(buffer, cancellationToken); + cancel.Cancel(); + return count; + } + } +} diff --git a/sandbox/Program.cs b/sandbox/Program.cs index 71ea549..d8e9772 100644 --- a/sandbox/Program.cs +++ b/sandbox/Program.cs @@ -9,6 +9,35 @@ internal class Program { static async Task Main(string[] args) { + if (args.Length > 0 && string.Equals(args[0], "lod2-atom-check", StringComparison.OrdinalIgnoreCase)) + { + await Lod2AtomChecks.RunAsync(); + return; + } + + if (args.Length > 0 && string.Equals(args[0], "lod2atom", StringComparison.OrdinalIgnoreCase)) + { + try { await Lod2AtomChecks.ProbeAsync(args); } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + Environment.ExitCode = 1; + } + return; + } + + if (args.Length > 0 && string.Equals(args[0], "source-reader-check", StringComparison.OrdinalIgnoreCase)) + { + SourceReaderChecks.Run(); + return; + } + + if (args.Length > 0 && string.Equals(args[0], "terrain-mesh-check", StringComparison.OrdinalIgnoreCase)) + { + TerrainMeshChecks.Run(); + return; + } + if (args.Length > 0 && string.Equals(args[0], "probe", StringComparison.OrdinalIgnoreCase)) { await RunProbeAsync(args); diff --git a/sandbox/SourceReaderChecks.cs b/sandbox/SourceReaderChecks.cs new file mode 100644 index 0000000..74e5d1d --- /dev/null +++ b/sandbox/SourceReaderChecks.cs @@ -0,0 +1,156 @@ +using RhinoSpatial.Core; + +namespace RhinoSpatial.Sandbox; + +internal static class SourceReaderChecks +{ + public static void Run() + { + var directory = Path.Combine(Path.GetTempPath(), "rhinospatial-reader-check-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var failures = new List(); + var count = 0; + try + { + void Check(string name, Action test) + { + count++; + try + { + test(); + Console.WriteLine($"PASS: {name}"); + } + catch (Exception exception) + { + failures.Add(name); + Console.WriteLine($"FAIL: {name}: {exception.Message}"); + } + } + + string Write(string name, string content) + { + var path = Path.Combine(directory, name); + File.WriteAllText(path, content); + return path; + } + + const string cornerHeader = "ncols 2\nnrows 2\nxllcorner 100\nyllcorner 200\ncellsize 10\n"; + const string centerHeader = "ncols 2\nnrows 2\nxllcenter 105\nyllcenter 205\ncellsize 10\n"; + var cornerPath = Write("corner.asc", cornerHeader + "NODATA_value -9999\n10 20\n30 40\n"); + var centerPath = Write("center.asc", centerHeader + "NODATA_value -9999\n10 20\n30 40\n"); + + foreach (var path in new[] { cornerPath, centerPath }) + { + Check($"ASCII {Path.GetFileName(path)} sample placement", () => + { + var result = TerrainTextGridReader.ReadRaster(path, "test", "EPSG:25832"); + Require(result.BoundingBox == new BoundingBox2D(105, 205, 115, 215), "Elevation samples must be positioned at cell centers."); + Require(result.Raster.Origin == new Coordinate2D(105, 215), "The first sample must be the top-left cell center."); + Require(result.Raster.Elevations.SequenceEqual(new float[] { 10, 20, 30, 40 }), "Sample order changed."); + }); + Check($"ASCII {Path.GetFileName(path)} reference extent", () => + { + var metadata = TerrainTextGridReader.ReadSourceMetadata(path, "EPSG:25832"); + Require(metadata.BoundingBox == new BoundingBox2D(100, 200, 120, 220), "Reference metadata must cover the full cell footprint."); + }); + } + + Check("ASCII optional NoData header", () => + { + var path = Write("default-nodata.asc", cornerHeader + "10 -9999\n30 40\n"); + var result = TerrainTextGridReader.ReadRaster(path, "test", "EPSG:25832"); + Require(result.Raster.Elevations.SequenceEqual(new float[] { 10, -9999, 30, 40 }), "The first data row was consumed as a header."); + Require(result.Raster.NoDataValue == -9999, "The default NoData sentinel must be -9999."); + }); + Check("ASCII wrapped rows", () => + { + var path = Write("wrapped.asc", cornerHeader + "NODATA_value -9999\n10\n20 30 40\n"); + var result = TerrainTextGridReader.ReadRaster(path, "test", "EPSG:25832"); + Require(result.Raster.Elevations.SequenceEqual(new float[] { 10, 20, 30, 40 }), "NCOLS, rather than text line breaks, must determine raster rows."); + }); + Check("ASCII truncated values rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadRaster( + Write("truncated.asc", cornerHeader + "NODATA_value -9999\n10\n30 40\n"), "test", "EPSG:25832"), "cell")); + Check("ASCII extra values rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadRaster( + Write("extra.asc", cornerHeader + "NODATA_value -9999\n10 20 25\n30 40\n"), "test", "EPSG:25832"), "cell")); + Check("ASCII invalid dimensions rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadSourceMetadata( + Write("dimensions.asc", cornerHeader.Replace("ncols 2", "ncols 0") + "NODATA_value -9999\n"), "EPSG:25832"), "ncols")); + Check("ASCII invalid cell size rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadSourceMetadata( + Write("cellsize.asc", cornerHeader.Replace("cellsize 10", "cellsize -10") + "NODATA_value -9999\n"), "EPSG:25832"), "cellsize")); + Check("ASCII mixed origin conventions rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadSourceMetadata( + Write("mixed.asc", cornerHeader.Replace("xllcorner", "xllcenter") + "NODATA_value -9999\n"), "EPSG:25832"), "origin")); + + Check("XYZ irregular spacing rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadRaster( + Write("irregular.xyz", "0 0 1\n1 0 2\n3 0 3\n0 1 4\n1 1 5\n3 1 6\n"), "test", "EPSG:25832"), "regular")); + Check("XYZ sparse regular grid preserves missing cells", () => + { + var result = TerrainTextGridReader.ReadRaster( + Write("sparse.xyz", "x,y,z\n0,0,1\n2,0,2\n0,2,3\n"), "test", "EPSG:25832"); + Require(result.Raster.Width == 2 && result.Raster.Height == 2, "Sparse grid dimensions changed."); + Require(float.IsNaN(result.Raster.Elevations[1]), "A missing sample must remain NoData."); + }); + Check("XYZ non-finite position rejected", () => + RequireInvalid(() => TerrainTextGridReader.ReadRaster( + Write("nonfinite.xyz", "0 0 1\n1 0 2\n0 1 3\nNaN 1 4\n"), "test", "EPSG:25832"), "finite")); + + Check("GeoJSON null attributes", () => + { + var features = GeoJsonReader.ReadFeatures(Collection("{\"type\":\"Point\",\"coordinates\":[8,50]}" , "null"), "test"); + Require(features.Single().Attributes.Count == 0, "Null properties should mean no attributes."); + Require(features.Single().Geometry.Points.Single() == new Coordinate2D(8, 50), "The valid point must survive."); + }); + foreach (var type in new[] { "Point", "Polygon", "MultiPoint", "LineString", "MultiLineString", "MultiPolygon" }) + { + Check($"GeoJSON empty {type}", () => + { + var feature = GeoJsonReader.ReadFeatures(Collection($"{{\"type\":\"{type}\",\"coordinates\":[]}}"), "test").Single(); + Require(!SpatialFeatureFilter.TryGetFeatureBounds(feature, out _), "Empty coordinates must not fabricate geometry at (0,0)."); + }); + } + Check("GeoJSON malformed point rejected", () => + RequireInvalid(() => GeoJsonReader.ReadFeatures(Collection("{\"type\":\"Point\",\"coordinates\":[8]}"), "test"), "coordinate")); + foreach (var name in new[] { "EPSG:25832", "urn:ogc:def:crs:EPSG::25832", "urn:ogc:def:crs:EPSG:6.6:25832", "http://www.opengis.net/def/crs/EPSG/0/25832", "http://www.opengis.net/gml/srs/epsg.xml#25832" }) + { + Check($"GeoJSON CRS {name}", () => + { + var json = System.Text.Json.JsonSerializer.Serialize(new { crs = new { type = "name", properties = new { name } } }); + Require(GeoJsonReader.TryReadSourceSrs(json) == "EPSG:25832", "A CRS version must not be mistaken for its EPSG code."); + }); + } + } + finally + { + Directory.Delete(directory, recursive: true); + } + + Require(failures.Count == 0, $"{failures.Count} of {count} source reader checks failed: {string.Join(", ", failures)}"); + Console.WriteLine($"All {count} source reader checks passed."); + } + + private static string Collection(string geometry, string properties = "{}") => + $"{{\"type\":\"FeatureCollection\",\"features\":[{{\"type\":\"Feature\",\"geometry\":{geometry},\"properties\":{properties}}}]}}"; + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private static void RequireInvalid(Action action, string expectedMessage) + { + try + { + action(); + } + catch (InvalidOperationException exception) when (exception.Message.Contains(expectedMessage, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException($"Expected a clear '{expectedMessage}' error."); + } +} diff --git a/sandbox/TerrainMeshChecks.cs b/sandbox/TerrainMeshChecks.cs new file mode 100644 index 0000000..85982e1 --- /dev/null +++ b/sandbox/TerrainMeshChecks.cs @@ -0,0 +1,84 @@ +using RhinoSpatial.Core; + +namespace RhinoSpatial.Sandbox; + +internal static class TerrainMeshChecks +{ + public static void Run() + { + var bounds = new BoundingBox2D(500000, 0, 500010, 10); + var raster = Raster("EPSG:25832", 2, 2, new float[] { 100, 110, 120, 130 }); + var context = Context("EPSG:4326", new Coordinate2D(8.9, 0)); + var mesh = TerrainMeshBuilder.Build(raster, bounds, context, 512, 25); + var lowerLeft = mesh.Vertices[2]; + Require(Math.Abs(lowerLeft.X - 0.1) < 1e-7 && Math.Abs(lowerLeft.Y) < 1e-7, + "Projected terrain must transform into the context CRS before localization (UTM zone 32 central meridian is 9 degrees east)."); + Require(lowerLeft.Z == 95 && mesh.Faces.Count == 1, "Terrain must retain its elevations relative to the shared baseline."); + + var absolute = TerrainMeshBuilder.Build(raster, bounds, context with { UseAbsoluteCoordinates = true }, 512, 25); + Require(Math.Abs(absolute.Vertices[2].X - 9) < 1e-7 && absolute.Vertices[2].Z == 120, + "Absolute mode must preserve full context coordinates and source elevation."); + + var sameCrs = TerrainMeshBuilder.Build(raster, bounds, Context("EPSG:25832", new Coordinate2D(500000, 0)), 512, 25); + Require(sameCrs.Vertices[0] == new Coordinate3D(0, 10, 75), "Existing same-CRS placement must remain stable."); + + var geographic = TerrainMeshBuilder.Build(Raster("EPSG:4326", 2, 2, new float[] { 1, 2, 3, 4 }), + new BoundingBox2D(0, 0, 1, 1), Context("EPSG:3857", new Coordinate2D(0, 0)), 512, 0); + Require(Math.Abs(geographic.Vertices[1].X - 111319.490793) < 0.001 && + Math.Abs(geographic.Vertices[1].Y - 111325.142866) < 0.001, + "The global geographic terrain path must still project into the selected context."); + + foreach (var missing in new[] { float.NaN, float.PositiveInfinity, -9999f }) + { + var values = Enumerable.Repeat(100f, 9).ToArray(); + values[0] = missing; + var sparse = TerrainMeshBuilder.Build(Raster("EPSG:25832", 3, 3, values) with { NoDataValue = -9999 }, + bounds, Context("EPSG:25832", new Coordinate2D(500000, 0)), 512, 25); + Require(sparse.Faces.Count == 3 && sparse.Vertices.Count == 8, + "Missing elevations must leave a hole, rather than creating a flat terrain patch."); + Require(sparse.Vertices.All(vertex => double.IsFinite(vertex.Z) && vertex.Z == 75), + "Non-finite and NoData elevations must never become vertices."); + } + + var edgeMesh = TerrainMeshBuilder.Build(Raster("EPSG:25832", 6, 4, Enumerable.Repeat(100f, 24).ToArray()), + new BoundingBox2D(0, 0, 5, 3), Context("EPSG:25832", new Coordinate2D(0, 0)), 3, 0); + Require(edgeMesh.Vertices.Count <= 9 && edgeMesh.Vertices[^1] == new Coordinate3D(5, 0, 100), + "Downsampling must retain the last raster row and column within the grid budget."); + + RequireInvalid(() => TerrainMeshBuilder.Build(raster with { SrsName = "EPSG:999999" }, bounds, context, 512, 25), "transform"); + RequireInvalid(() => TerrainMeshBuilder.Build(raster with { Elevations = new[] { float.NaN, float.NaN, float.NaN, float.NaN } }, + bounds, context, 512, 25), "usable"); + RequireInvalid(() => TerrainMeshBuilder.Build(raster with { Elevations = new[] { 100f } }, bounds, context, 512, 25), "matching"); + + var hole = Enumerable.Repeat(100f, 25).ToArray(); + hole[12] = float.NaN; + RequireInvalid(() => TerrainMeshBuilder.Build(Raster("EPSG:25832", 5, 5, hole), bounds, context, 2, 25), "usable"); + + Console.WriteLine("Terrain mesh CRS, localization, absolute elevation, NoData, and boundary checks passed."); + } + + private static TerrainRasterData Raster(string srs, int width, int height, float[] elevations) => + new("test", srs, width, height, new Coordinate2D(0, 0), new Coordinate2D(1, 0), new Coordinate2D(0, -1), null, elevations); + + private static SpatialContext2D Context(string srs, Coordinate2D origin) => + new(srs, new BoundingBox2D(0, 0, 1, 1), new BoundingBox2D(0, 0, 1, 1), null, origin, false, new()); + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private static void RequireInvalid(Action action, string expectedMessage) + { + try + { + action(); + } + catch (InvalidOperationException exception) when (exception.Message.Contains(expectedMessage, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException($"Expected a clear '{expectedMessage}' error."); + } +} diff --git a/scripts/build-food4rhino-zip.sh b/scripts/build-food4rhino-zip.sh index 2d16adc..b5e75c4 100755 --- a/scripts/build-food4rhino-zip.sh +++ b/scripts/build-food4rhino-zip.sh @@ -8,7 +8,7 @@ FRAMEWORK="net7.0" PLUGIN_BUILD_DIR="$ROOT_DIR/RhinoSpatial/bin/$CONFIGURATION/$FRAMEWORK" STAGING_DIR="$ROOT_DIR/package/staging/food4rhino" PROJECT_VERSION="$(sed -nE 's:.*([^<]+).*:\1:p' "$ROOT_DIR/RhinoSpatial/RhinoSpatial.csproj" | head -n 1)" -VERSION="${PROJECT_VERSION}-alpha" +VERSION="$PROJECT_VERSION" ZIP_NAME="RhinoSpatial-${VERSION}.zip" dotnet build "$ROOT_DIR/RhinoSpatial.sln" -c "$CONFIGURATION" diff --git a/scripts/build-yak.sh b/scripts/build-yak.sh index b658dc0..a6e8dfc 100755 --- a/scripts/build-yak.sh +++ b/scripts/build-yak.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CONFIGURATION="${1:-Release}" +PLATFORM="${2:-any}" FRAMEWORK="net7.0" YAK_BIN="/Applications/Rhino 8.app/Contents/Resources/bin/yak" PLUGIN_BUILD_DIR="$ROOT_DIR/RhinoSpatial/bin/$CONFIGURATION/$FRAMEWORK" @@ -32,7 +33,7 @@ find "$PLUGIN_BUILD_DIR" -maxdepth 1 -type f \ ( cd "$STAGING_DIR" - "$YAK_BIN" build --platform mac + "$YAK_BIN" build --platform "$PLATFORM" ) echo