From 749cbf6ab94cd3606002b7d5e2cb97b4a563487b Mon Sep 17 00:00:00 2001 From: Maggie Moeller Date: Wed, 17 Jun 2026 11:49:17 -0400 Subject: [PATCH 01/11] Restore SnappingOptions runtime setters (4.5.x regression) The 4.5.x release accidentally truncated SnappingOptions.gb.cs right after the property getters (commit c3ab36411), dropping the entire Property Setters and collection-helper regions. This removed SetEnabled, SetGridEnabled, SetDistance, SetFeatureEnabled, SetSelfEnabled, SetAttributeRulesEnabled, and SetFeatureSources, breaking runtime snapping/grid toggling for consumers (reported against 4.5.1). Restores the complete generated file from f02afefeb (the commit immediately before the truncation), which re-adds exactly the 7 setters plus the Add/Remove FeatureSources collection helpers and nothing else. Adds SnappingOptionsApiTests as a public-API contract guard so a future code-generation split cannot silently drop these members again. Versioning/release left to the maintainer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Components/SnappingOptions.gb.cs | 309 ++++++++++++++++++ .../SnappingOptionsApiTests.cs | 68 ++++ 2 files changed, 377 insertions(+) create mode 100644 test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs diff --git a/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs index b892f6a4a..fb600587d 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/SnappingOptions.gb.cs @@ -390,6 +390,315 @@ public SnappingOptions( return SelfEnabled; } +#endregion + +#region Property Setters + + /// + /// Asynchronously set the value of the AttributeRulesEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetAttributeRulesEnabled(bool? value) + { +#pragma warning disable BL0005 + AttributeRulesEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(AttributeRulesEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "attributeRulesEnabled", value); + } + + /// + /// Asynchronously set the value of the Distance property after render. + /// + /// + /// The value to set. + /// + public async Task SetDistance(double? value) + { +#pragma warning disable BL0005 + Distance = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(Distance)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "distance", value); + } + + /// + /// Asynchronously set the value of the Enabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetEnabled(bool? value) + { +#pragma warning disable BL0005 + Enabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(Enabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "enabled", value); + } + + /// + /// Asynchronously set the value of the FeatureEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetFeatureEnabled(bool? value) + { +#pragma warning disable BL0005 + FeatureEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(FeatureEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "featureEnabled", value); + } + + /// + /// Asynchronously set the value of the FeatureSources property after render. + /// + /// + /// The value to set. + /// + public async Task SetFeatureSources(IReadOnlyList? value) + { + if (value is not null) + { + foreach (FeatureSnappingLayerSource item in value) + { + item.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); + } + } + +#pragma warning disable BL0005 + FeatureSources = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(FeatureSources)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "featureSources", value); + } + + /// + /// Asynchronously set the value of the GridEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetGridEnabled(bool? value) + { +#pragma warning disable BL0005 + GridEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(GridEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "gridEnabled", value); + } + + /// + /// Asynchronously set the value of the SelfEnabled property after render. + /// + /// + /// The value to set. + /// + public async Task SetSelfEnabled(bool? value) + { +#pragma warning disable BL0005 + SelfEnabled = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(SelfEnabled)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, + JsComponentReference, "selfEnabled", value); + } + +#endregion + +#region Add to Collection Methods + + /// + /// Asynchronously adds elements to the FeatureSources property. + /// + /// + /// The elements to add. + /// + public async Task AddToFeatureSources(params FeatureSnappingLayerSource[] values) + { + FeatureSnappingLayerSource[] join = FeatureSources is null + ? values + : [..FeatureSources, ..values]; + await SetFeatureSources(join); + } + +#endregion + +#region Remove From Collection Methods + + + /// + /// Asynchronously remove an element from the FeatureSources property. + /// + /// + /// The elements to remove. + /// + public async Task RemoveFromFeatureSources(params FeatureSnappingLayerSource[] values) + { + if (FeatureSources is null) + { + return; + } + await SetFeatureSources(FeatureSources.Except(values).ToArray()); + } + #endregion diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs new file mode 100644 index 000000000..19ec255e9 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs @@ -0,0 +1,68 @@ +using dymaptic.GeoBlazor.Core.Components; +using System.Reflection; + +namespace dymaptic.GeoBlazor.Core.Test.Unit; + +/// +/// Public-API contract tests for . +/// The 4.5.x release dropped the runtime setter methods (and collection helpers) from the +/// generated component — SnappingOptions.gb.cs was truncated right after the getters — +/// breaking consumers who toggled snapping / grid snapping at runtime +/// (SetEnabled, SetGridEnabled, etc.). +/// These tests lock in the public API surface so a future code-generation split cannot +/// silently remove it again. +/// +[TestClass] +public class SnappingOptionsApiTests +{ + private static readonly Type SnappingOptionsType = typeof(SnappingOptions); + + [TestMethod] + [DataRow("SetEnabled")] + [DataRow("SetGridEnabled")] + [DataRow("SetFeatureEnabled")] + [DataRow("SetSelfEnabled")] + public void HasRuntimeNullableBoolSetter(string methodName) + { + MethodInfo? method = SnappingOptionsType.GetMethod(methodName, + BindingFlags.Public | BindingFlags.Instance, [typeof(bool?)]); + + Assert.IsNotNull(method, + $"SnappingOptions.{methodName}(bool?) must exist for runtime snapping toggling (regression from 4.5.x)."); + Assert.AreEqual(typeof(Task), method.ReturnType, + $"SnappingOptions.{methodName}(bool?) should return Task."); + } + + [TestMethod] + [DataRow("Enabled")] + [DataRow("GridEnabled")] + [DataRow("FeatureEnabled")] + [DataRow("SelfEnabled")] + public void HasNullableBoolParameter(string propertyName) + { + PropertyInfo? property = SnappingOptionsType.GetProperty(propertyName, + BindingFlags.Public | BindingFlags.Instance); + + Assert.IsNotNull(property, + $"SnappingOptions.{propertyName} property must exist (regression from 4.5.x)."); + Assert.AreEqual(typeof(bool?), property.PropertyType, + $"SnappingOptions.{propertyName} should be a bool? property."); + Assert.IsTrue(property.CanRead && property.CanWrite, + $"SnappingOptions.{propertyName} should be readable and writable."); + } + + [TestMethod] + public void SetEnabled_UpdatesLocalProperty_BeforeRender() + { + // Without a JS runtime the setter should still update the local value (used for binding / + // initial render). The customer reported that even binding Enabled "didn't take effect". + SnappingOptions options = new(); + + // Should not throw when there is no JS component yet; should set the local property. + options.SetEnabled(true).GetAwaiter().GetResult(); + Assert.IsTrue(options.Enabled.GetValueOrDefault(), "SetEnabled should update the local Enabled value."); + + options.SetGridEnabled(true).GetAwaiter().GetResult(); + Assert.IsTrue(options.GridEnabled.GetValueOrDefault(), "SetGridEnabled should update the local GridEnabled value."); + } +} From 71437ba478c311e096447031f2462f6c495c37ac Mon Sep 17 00:00:00 2001 From: Maggie Moeller Date: Wed, 17 Jun 2026 12:13:46 -0400 Subject: [PATCH 02/11] Add Union/Extent regression guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer reported (against 4.5.1) that GeometryEngine.Union returned a geometry with a null Extent, breaking map.GoTo(union.Extent). The union/extent code path is unchanged from the working 4.4.4 line, so this is not reproducible from source — these tests lock in the behavior so any future change that drops the extent is caught: - GeometryExtentSerializationTests: unit-level guard deserializing the exact JSON the JS union() produces and asserting Extent (and a full round-trip) survive. - GeometryEngineTests.TestUnion: extends the browser test to assert the unioned geometry has the expected Extent bounds and that GetExtent() returns it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Components/GeometryEngineTests.cs | 14 ++++ .../GeometryExtentSerializationTests.cs | 71 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs index 471c1c90c..183a1592a 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs @@ -1399,6 +1399,20 @@ public async Task TestUnion() Assert.IsNotNull(union); Assert.AreNotEqual(polygon1, union); Assert.AreNotEqual(polygon2, union); + + // Regression (reported against 4.5.1): the unioned geometry must come back with a populated + // Extent so callers can use it for map.GoTo(extent). Both the inline Extent and GetExtent() + // must work. polygon1 spans x:[0,10] and polygon2 spans x:[10,20]; together y:[0,10]. + Assert.IsNotNull(union.Extent, + "Union result should have a populated Extent."); + Assert.AreEqual(0.0, union.Extent!.Xmin); + Assert.AreEqual(20.0, union.Extent!.Xmax); + Assert.AreEqual(0.0, union.Extent!.Ymin); + Assert.AreEqual(10.0, union.Extent!.Ymax); + + Extent? fetchedExtent = await union.GetExtent(); + Assert.IsNotNull(fetchedExtent, + "GetExtent() should return the unioned geometry's extent."); } [TestMethod] diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs new file mode 100644 index 000000000..c1120541b --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentSerializationTests.cs @@ -0,0 +1,71 @@ +using dymaptic.GeoBlazor.Core.Components.Geometries; +using dymaptic.GeoBlazor.Core.Serialization; +using System.Text.Json; + +namespace dymaptic.GeoBlazor.Core.Test.Unit; + +/// +/// Regression tests for the geometry deserialization path used by results of +/// GeometryEngine operations (e.g. Union). +/// The customer reported (against 4.5.1) that the unioned geometry arrived in .NET with a +/// null Extent (and therefore GetExtent() also returned null), +/// breaking callers that relied on union.Extent for map.GoTo(extent). +/// These tests deserialize the exact JSON shape produced by buildDotNetPolygon / +/// buildDotNetExtent (polygon.ts / extent.ts) and assert the Extent survives. +/// +[TestClass] +public class GeometryExtentSerializationTests +{ + // Mirrors the object returned by buildDotNetPolygon(jsUnion) in geometryEngine.ts -> union(), + // including the nested extent built by buildDotNetExtent. + private const string UnionPolygonJson = + """ + { + "type": "polygon", + "rings": [[[0,0],[0,10],[20,10],[20,0],[0,0]]], + "spatialReference": { "wkid": 102100 }, + "extent": { + "type": "extent", + "xmin": 0, "ymin": 0, "xmax": 20, "ymax": 10, + "spatialReference": { "wkid": 102100 } + }, + "isSimple": true + } + """; + + [TestMethod] + public void UnionResult_DeserializesWithPopulatedExtent() + { + Geometry? geometry = JsonSerializer.Deserialize(UnionPolygonJson, + GeoBlazorSerialization.JsonSerializerOptions); + + Assert.IsNotNull(geometry); + Assert.IsInstanceOfType(geometry); + Assert.IsNotNull(geometry.Extent, + "The geometry returned from Union must have a populated Extent (regression from 4.5.x)."); + Assert.AreEqual(0.0, geometry.Extent!.Xmin); + Assert.AreEqual(0.0, geometry.Extent!.Ymin); + Assert.AreEqual(20.0, geometry.Extent!.Xmax); + Assert.AreEqual(10.0, geometry.Extent!.Ymax); + Assert.IsNotNull(geometry.Extent!.SpatialReference); + } + + [TestMethod] + public void Polygon_RoundTrip_PreservesExtent() + { + Geometry? geometry = JsonSerializer.Deserialize(UnionPolygonJson, + GeoBlazorSerialization.JsonSerializerOptions); + Assert.IsNotNull(geometry?.Extent); + + // Re-serialize and re-deserialize to ensure the Extent is not dropped on the way back out. + string json = JsonSerializer.Serialize(geometry, GeoBlazorSerialization.JsonSerializerOptions); + Geometry? roundTripped = JsonSerializer.Deserialize(json, + GeoBlazorSerialization.JsonSerializerOptions); + + Assert.IsNotNull(roundTripped); + Assert.IsNotNull(roundTripped.Extent, + "Extent must survive a full serialize/deserialize round-trip."); + Assert.AreEqual(geometry.Extent!.Xmax, roundTripped.Extent!.Xmax); + Assert.AreEqual(geometry.Extent!.Ymax, roundTripped.Extent!.Ymax); + } +} From 55a93a5eeb9438bc0f1f93fcb3be8e4454ffb1a1 Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:46:03 +0000 Subject: [PATCH 03/11] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 05d3d47be..ac2ae0ea6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.2 + 4.5.2.1 Debug;Release;SourceGen Highlighting AnyCPU true From ad7862fcc17852b8646ab158049e6c99252d91c0 Mon Sep 17 00:00:00 2001 From: Maggie Moeller Date: Wed, 17 Jun 2026 12:59:24 -0400 Subject: [PATCH 04/11] Expand SnappingOptions API contract test to the full surface Addresses Copilot review: the contract test only guarded the 4 bool? setters and 4 bool? properties. Since the goal is to catch any future truncation of the generated runtime API, cover everything the 4.5.x cut removed: - all 7 setters (SetEnabled/GridEnabled/FeatureEnabled/SelfEnabled/AttributeRulesEnabled, plus SetDistance(double?) and SetFeatureSources(IReadOnlyList<...>)) - all 7 parameters (the 5 bool?, Distance, FeatureSources) - the AddToFeatureSources/RemoveFromFeatureSources collection helpers Also makes the behavioral test async/await instead of blocking on GetResult(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SnappingOptionsApiTests.cs | 83 ++++++++++++++++--- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs index 19ec255e9..7fb317d7e 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs @@ -9,60 +9,117 @@ namespace dymaptic.GeoBlazor.Core.Test.Unit; /// generated component — SnappingOptions.gb.cs was truncated right after the getters — /// breaking consumers who toggled snapping / grid snapping at runtime /// (SetEnabled, SetGridEnabled, etc.). -/// These tests lock in the public API surface so a future code-generation split cannot -/// silently remove it again. +/// These tests lock in the full runtime public API surface (every setter, every parameter, and +/// the collection helpers) so a future code-generation split cannot silently remove it again. /// [TestClass] public class SnappingOptionsApiTests { private static readonly Type SnappingOptionsType = typeof(SnappingOptions); + // --- Runtime setters (the whole region was dropped by the 4.5.x truncation) --- + [TestMethod] [DataRow("SetEnabled")] [DataRow("SetGridEnabled")] [DataRow("SetFeatureEnabled")] [DataRow("SetSelfEnabled")] - public void HasRuntimeNullableBoolSetter(string methodName) + [DataRow("SetAttributeRulesEnabled")] + public void HasNullableBoolSetter(string methodName) + { + AssertRuntimeSetter(methodName, typeof(bool?)); + } + + [TestMethod] + public void HasDistanceSetter() + { + AssertRuntimeSetter("SetDistance", typeof(double?)); + } + + [TestMethod] + public void HasFeatureSourcesSetter() + { + AssertRuntimeSetter("SetFeatureSources", typeof(IReadOnlyList)); + } + + private static void AssertRuntimeSetter(string methodName, Type parameterType) + { + MethodInfo? method = SnappingOptionsType.GetMethod(methodName, + BindingFlags.Public | BindingFlags.Instance, [parameterType]); + + Assert.IsNotNull(method, + $"SnappingOptions.{methodName}({parameterType.Name}) must exist for runtime configuration (regression from 4.5.x)."); + Assert.AreEqual(typeof(Task), method.ReturnType, + $"SnappingOptions.{methodName} should return Task."); + } + + // --- Collection helpers (also dropped by the truncation) --- + + [TestMethod] + [DataRow("AddToFeatureSources")] + [DataRow("RemoveFromFeatureSources")] + public void HasFeatureSourcesCollectionHelper(string methodName) { MethodInfo? method = SnappingOptionsType.GetMethod(methodName, - BindingFlags.Public | BindingFlags.Instance, [typeof(bool?)]); + BindingFlags.Public | BindingFlags.Instance, [typeof(FeatureSnappingLayerSource[])]); Assert.IsNotNull(method, - $"SnappingOptions.{methodName}(bool?) must exist for runtime snapping toggling (regression from 4.5.x)."); + $"SnappingOptions.{methodName}(params FeatureSnappingLayerSource[]) must exist (regression from 4.5.x)."); Assert.AreEqual(typeof(Task), method.ReturnType, - $"SnappingOptions.{methodName}(bool?) should return Task."); + $"SnappingOptions.{methodName} should return Task."); } + // --- Public properties / Blazor parameters --- + [TestMethod] [DataRow("Enabled")] [DataRow("GridEnabled")] [DataRow("FeatureEnabled")] [DataRow("SelfEnabled")] + [DataRow("AttributeRulesEnabled")] public void HasNullableBoolParameter(string propertyName) + { + AssertParameter(propertyName, typeof(bool?)); + } + + [TestMethod] + public void HasDistanceParameter() + { + AssertParameter("Distance", typeof(double?)); + } + + [TestMethod] + public void HasFeatureSourcesParameter() + { + AssertParameter("FeatureSources", typeof(IReadOnlyList)); + } + + private static void AssertParameter(string propertyName, Type propertyType) { PropertyInfo? property = SnappingOptionsType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); Assert.IsNotNull(property, $"SnappingOptions.{propertyName} property must exist (regression from 4.5.x)."); - Assert.AreEqual(typeof(bool?), property.PropertyType, - $"SnappingOptions.{propertyName} should be a bool? property."); + Assert.AreEqual(propertyType, property.PropertyType, + $"SnappingOptions.{propertyName} should be of type {propertyType.Name}."); Assert.IsTrue(property.CanRead && property.CanWrite, $"SnappingOptions.{propertyName} should be readable and writable."); } + // --- Behavioral: setters update local state before render --- + [TestMethod] - public void SetEnabled_UpdatesLocalProperty_BeforeRender() + public async Task Setters_UpdateLocalProperty_BeforeRender() { - // Without a JS runtime the setter should still update the local value (used for binding / + // Without a JS runtime the setters should still update the local value (used for binding / // initial render). The customer reported that even binding Enabled "didn't take effect". SnappingOptions options = new(); - // Should not throw when there is no JS component yet; should set the local property. - options.SetEnabled(true).GetAwaiter().GetResult(); + await options.SetEnabled(true); Assert.IsTrue(options.Enabled.GetValueOrDefault(), "SetEnabled should update the local Enabled value."); - options.SetGridEnabled(true).GetAwaiter().GetResult(); + await options.SetGridEnabled(true); Assert.IsTrue(options.GridEnabled.GetValueOrDefault(), "SetGridEnabled should update the local GridEnabled value."); } } From fd74d3f36a58fa734b6bbd551b2154c3fbe1a319 Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:08:33 +0000 Subject: [PATCH 05/11] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index ac2ae0ea6..14e191d79 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.2.1 + 4.5.2.2 Debug;Release;SourceGen Highlighting AnyCPU true From c5f7b1690cfb793227123d317a569161b271bc6a Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Wed, 17 Jun 2026 15:28:12 -0400 Subject: [PATCH 06/11] Populate geometry Extent for operator results (Union extent regression) buildDotNetGeometry now computes a missing extent from the geometry's coordinates, so results from the operator API (e.g. unionOperator) carry an Extent instead of null. Geometry.GetExtent() also falls back to a client-side CalculateExtent() (overridden by Polygon/Polyline) when neither the cached value nor the JS component supplies one. Adds extent assertions to TestUnionWithParamsArray and a GeometryExtentTests unit test for the calculation fallback. Co-Authored-By: Claude Opus 4.8 --- .../Components/Geometries/Geometry.cs | 79 ++++++++++++++++--- .../Components/Geometries/Polygon.cs | 6 +- .../Components/Geometries/Polyline.cs | 4 + .../Scripts/geometry.ts | 61 +++++++++++++- .../Components/GeometryEngineTests.cs | 9 +++ .../GeometryExtentTests.cs | 63 +++++++++++++++ 6 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs index 5425cde14..990f27def 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs @@ -111,24 +111,85 @@ public override void ValidateRequiredChildren() /// public async Task GetExtent() { - if (CoreJsModule is null) + if (CoreJsModule is not null) { - return Extent; - } + JsComponentReference ??= await CoreJsModule.InvokeAsync("getJsComponent", CancellationTokenSource.Token, Id); - JsComponentReference ??= await CoreJsModule.InvokeAsync("getJsComponent", CancellationTokenSource.Token, Id); - if (JsComponentReference is null) - { - return Extent; + if (JsComponentReference is not null) + { + // get the property value + Extent? jsExtent = await CoreJsModule!.InvokeAsync("getProperty", CancellationTokenSource.Token, JsComponentReference, "extent"); + + if (jsExtent is not null) + { +#pragma warning disable BL0005 + Extent = jsExtent; +#pragma warning restore BL0005 + } + } } - // get the property value + // Fall back to calculating the extent client-side when neither the cached value + // nor the JS component provided one (e.g. geometries created in C#, or returned + // from operators that do not expose a cached extent). + if (Extent is null) + { #pragma warning disable BL0005 - Extent = await CoreJsModule!.InvokeAsync("getProperty", CancellationTokenSource.Token, JsComponentReference, "extent"); + Extent = CalculateExtent(); #pragma warning restore BL0005 + } + return Extent; } + /// + /// Calculates the (bounding box) of this geometry from its + /// coordinates. Returns the existing by default; vertex-based + /// geometries (e.g. , ) override this to + /// compute a missing extent. + /// + [CodeGenerationIgnore] + protected virtual Extent? CalculateExtent() => Extent; + + /// + /// Computes a bounding-box over a set of vertex paths/rings, + /// or when there are no coordinates. + /// + [CodeGenerationIgnore] + protected static Extent? CalculateExtentFromPaths(IEnumerable? paths, + SpatialReference? spatialReference) + { + if (paths is null) + { + return null; + } + + double xmin = double.MaxValue, ymin = double.MaxValue; + double xmax = double.MinValue, ymax = double.MinValue; + bool any = false; + + foreach (MapPath path in paths) + { + foreach (MapPoint point in path) + { + if (point.Count < 2) + { + continue; + } + + any = true; + if (point[0] < xmin) xmin = point[0]; + if (point[0] > xmax) xmax = point[0]; + if (point[1] < ymin) ymin = point[1]; + if (point[1] > ymax) ymax = point[1]; + } + } + + return any + ? new Extent(xmax, xmin, ymax, ymin, spatialReference: spatialReference) + : null; + } + internal abstract GeometrySerializationRecord ToSerializationRecord(); } diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs index 418b8b3fc..c03f5e555 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs @@ -106,7 +106,11 @@ public Polygon( public override GeometryType Type => GeometryType.Polygon; #endregion - + + /// + [CodeGenerationIgnore] + protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Rings, SpatialReference); + /// /// Returns a deep clone of the geometry. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs index d6d656e86..4c443d45e 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs @@ -23,6 +23,10 @@ public partial class Polyline : Geometry /// public override GeometryType Type => GeometryType.Polyline; + /// + [CodeGenerationIgnore] + protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Paths, SpatialReference); + /// /// Returns a deep clone of the geometry. /// diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts index 0bb27900e..4b5a95662 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts @@ -10,20 +10,75 @@ export function buildDotNetGeometry(geometry: any): any { if (!hasValue(geometry)) { return null; } + let dotNetGeometry: any; switch (geometry?.type) { case "point": return buildDotNetPoint(geometry); case "polyline": - return buildDotNetPolyline(geometry); + dotNetGeometry = buildDotNetPolyline(geometry); + break; case "polygon": - return buildDotNetPolygon(geometry); + dotNetGeometry = buildDotNetPolygon(geometry); + break; case "extent": return buildDotNetExtent(geometry); case "multipoint": - return buildDotNetMultipoint(geometry); + dotNetGeometry = buildDotNetMultipoint(geometry); + break; case "mesh": return buildDotNetMesh(geometry); + default: + return undefined; } + + // Geometries returned from the operator API (e.g. unionOperator) do not always + // expose a cached `extent`, which previously left the .NET Geometry.Extent null + // (breaking e.g. view.GoTo(extent)). Always populate a missing extent, computing + // it from the geometry's coordinates when the geometry does not provide one. + if (hasValue(dotNetGeometry) && !hasValue(dotNetGeometry.extent)) { + let calculatedExtent = calculateGeometryExtent(geometry); + if (hasValue(calculatedExtent)) { + dotNetGeometry.extent = buildDotNetExtent(calculatedExtent); + } + } + + return dotNetGeometry; +} + +// Returns an extent-like object ({ xmin, ymin, xmax, ymax, spatialReference }) for a +// vertex-based geometry (polygon/polyline/multipoint). Prefers the geometry's own +// (lazily computed) extent and falls back to a bounding box over its raw coordinates. +function calculateGeometryExtent(geometry: any): any { + if (hasValue(geometry?.extent)) { + return geometry.extent; + } + + let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; + let consider = (pt: Array) => { + if (!hasValue(pt) || pt.length < 2) return; + if (pt[0] < xmin) xmin = pt[0]; + if (pt[0] > xmax) xmax = pt[0]; + if (pt[1] < ymin) ymin = pt[1]; + if (pt[1] > ymax) ymax = pt[1]; + }; + + if (hasValue(geometry?.rings)) { + for (let ring of geometry.rings) for (let pt of ring) consider(pt); + } else if (hasValue(geometry?.paths)) { + for (let path of geometry.paths) for (let pt of path) consider(pt); + } else if (hasValue(geometry?.points)) { + for (let pt of geometry.points) consider(pt); + } + + if (xmin === Infinity) { + return null; + } + + return { + type: 'extent', + xmin, ymin, xmax, ymax, + spatialReference: geometry?.spatialReference ?? null + }; } export function buildJsGeometry(geometry: any): any { diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs index 183a1592a..175a47f47 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/GeometryEngineTests.cs @@ -1949,6 +1949,15 @@ public async Task TestUnionWithParamsArray() Assert.AreNotEqual(polygon1, union); Assert.AreNotEqual(polygon2, union); Assert.AreNotEqual(polygon3, union); + + // Regression (reported against 4.5.1): the unioned geometry must come back with a populated + // Extent. The three polygons together span x:[0,30] and y:[0,10]. + Assert.IsNotNull(union.Extent, + "Union result should have a populated Extent."); + Assert.AreEqual(0.0, union.Extent!.Xmin); + Assert.AreEqual(30.0, union.Extent!.Xmax); + Assert.AreEqual(0.0, union.Extent!.Ymin); + Assert.AreEqual(10.0, union.Extent!.Ymax); } [TestMethod] diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs new file mode 100644 index 000000000..5e3a1f094 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs @@ -0,0 +1,63 @@ +using dymaptic.GeoBlazor.Core.Components; +using dymaptic.GeoBlazor.Core.Components.Geometries; +using dymaptic.GeoBlazor.Core.Model; + + +namespace dymaptic.GeoBlazor.Core.Test.Unit; + +/// +/// Regression tests for the client-side extent calculation added to . +/// The customer reported (against 4.5.1) that a geometry without a cached extent (e.g. a +/// GeometryEngine.Union result) returned a null Extent, breaking map.GoTo(extent). +/// With no CoreJsModule attached, GetExtent() must fall back to calculating the +/// bounding box from the geometry's own coordinates. +/// +[TestClass] +public class GeometryExtentTests +{ + [TestMethod] + public async Task GetExtentCalculatesMissingExtentForPolygon() + { + Polygon polygon = new( + [ + new MapPath( + new MapPoint(0, 0), + new MapPoint(0, 10), + new MapPoint(20, 10), + new MapPoint(20, 0), + new MapPoint(0, 0)) + ], + new SpatialReference(102100)); + + Assert.IsNull(polygon.Extent); + + Extent? extent = await polygon.GetExtent(); + + Assert.IsNotNull(extent); + Assert.AreEqual(0, extent!.Xmin); + Assert.AreEqual(20, extent.Xmax); + Assert.AreEqual(0, extent.Ymin); + Assert.AreEqual(10, extent.Ymax); + } + + [TestMethod] + public async Task GetExtentCalculatesMissingExtentForPolyline() + { + Polyline polyline = new( + [ + new MapPath( + new MapPoint(-5, 2), + new MapPoint(15, -3), + new MapPoint(7, 9)) + ], + new SpatialReference(102100)); + + Extent? extent = await polyline.GetExtent(); + + Assert.IsNotNull(extent); + Assert.AreEqual(-5, extent!.Xmin); + Assert.AreEqual(15, extent.Xmax); + Assert.AreEqual(-3, extent.Ymin); + Assert.AreEqual(9, extent.Ymax); + } +} From 3de5fadf1ed4b87acffe7484690dd43029897d9b Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:33:18 +0000 Subject: [PATCH 07/11] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 14e191d79..6c4cc3145 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.2.2 + 4.5.2.3 Debug;Release;SourceGen Highlighting AnyCPU true From fe23867e5d96fea83c7a7c9ff02e790e2a46fe24 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 19 Jun 2026 08:00:52 -0400 Subject: [PATCH 08/11] Include Z/M bounds in calculated extent fallback Addresses PR review: the extent computed by CalculateExtentFromPaths (C#) and calculateGeometryExtent (geometry.ts) only carried x/y. For Z/M-enabled geometries it now also tracks zmin/zmax and mmin/mmax (z at coord index 2 when hasZ; m at index 3 when hasZ, else 2), matching geometry.extent for 3D/M results. Adds Z (polygon) and M (polyline) regression tests. Co-Authored-By: Claude Opus 4.8 --- .../Components/Geometries/Geometry.cs | 41 +++++++++++--- .../Components/Geometries/Polygon.cs | 2 +- .../Components/Geometries/Polyline.cs | 2 +- .../Scripts/geometry.ts | 32 ++++++++++- .../GeometryExtentTests.cs | 54 +++++++++++++++++++ 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs index 990f27def..7b4acbe7f 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs @@ -153,20 +153,28 @@ public override void ValidateRequiredChildren() /// /// Computes a bounding-box over a set of vertex paths/rings, - /// or when there are no coordinates. + /// or when there are no coordinates. When the geometry has + /// Z and/or M values, the resulting extent's Z/M bounds are populated as well. /// [CodeGenerationIgnore] protected static Extent? CalculateExtentFromPaths(IEnumerable? paths, - SpatialReference? spatialReference) + SpatialReference? spatialReference, bool? hasZ = null, bool? hasM = null) { if (paths is null) { return null; } + // Coordinates are [x, y, z?, m?]: z is at index 2 when hasZ; m is at index 3 + // when hasZ, otherwise index 2. + int zIndex = hasZ == true ? 2 : -1; + int mIndex = hasM == true ? (hasZ == true ? 3 : 2) : -1; + double xmin = double.MaxValue, ymin = double.MaxValue; double xmax = double.MinValue, ymax = double.MinValue; - bool any = false; + double zmin = double.MaxValue, zmax = double.MinValue; + double mmin = double.MaxValue, mmax = double.MinValue; + bool any = false, anyZ = false, anyM = false; foreach (MapPath path in paths) { @@ -182,12 +190,33 @@ public override void ValidateRequiredChildren() if (point[0] > xmax) xmax = point[0]; if (point[1] < ymin) ymin = point[1]; if (point[1] > ymax) ymax = point[1]; + + if (zIndex >= 0 && point.Count > zIndex) + { + anyZ = true; + if (point[zIndex] < zmin) zmin = point[zIndex]; + if (point[zIndex] > zmax) zmax = point[zIndex]; + } + + if (mIndex >= 0 && point.Count > mIndex) + { + anyM = true; + if (point[mIndex] < mmin) mmin = point[mIndex]; + if (point[mIndex] > mmax) mmax = point[mIndex]; + } } } - return any - ? new Extent(xmax, xmin, ymax, ymin, spatialReference: spatialReference) - : null; + if (!any) + { + return null; + } + + return new Extent(xmax, xmin, ymax, ymin, + zmax: anyZ ? zmax : null, zmin: anyZ ? zmin : null, + mmax: anyM ? mmax : null, mmin: anyM ? mmin : null, + spatialReference: spatialReference, + hasM: anyM ? true : hasM, hasZ: anyZ ? true : hasZ); } internal abstract GeometrySerializationRecord ToSerializationRecord(); diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs index c03f5e555..671f2786a 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs @@ -109,7 +109,7 @@ public Polygon( /// [CodeGenerationIgnore] - protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Rings, SpatialReference); + protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Rings, SpatialReference, HasZ, HasM); /// /// Returns a deep clone of the geometry. diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs index 4c443d45e..03276583a 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs @@ -25,7 +25,7 @@ public partial class Polyline : Geometry /// [CodeGenerationIgnore] - protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Paths, SpatialReference); + protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Paths, SpatialReference, HasZ, HasM); /// /// Returns a deep clone of the geometry. diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts index 4b5a95662..00ce5d2cc 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts @@ -53,13 +53,32 @@ function calculateGeometryExtent(geometry: any): any { return geometry.extent; } + // Coordinates are [x, y, z?, m?]: z is at index 2 when hasZ; m is at index 3 when + // hasZ, otherwise index 2. + let hasZ = geometry?.hasZ === true; + let hasM = geometry?.hasM === true; + let zIndex = hasZ ? 2 : -1; + let mIndex = hasM ? (hasZ ? 3 : 2) : -1; + let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; + let zmin = Infinity, zmax = -Infinity, mmin = Infinity, mmax = -Infinity; + let anyZ = false, anyM = false; let consider = (pt: Array) => { if (!hasValue(pt) || pt.length < 2) return; if (pt[0] < xmin) xmin = pt[0]; if (pt[0] > xmax) xmax = pt[0]; if (pt[1] < ymin) ymin = pt[1]; if (pt[1] > ymax) ymax = pt[1]; + if (zIndex >= 0 && pt.length > zIndex) { + anyZ = true; + if (pt[zIndex] < zmin) zmin = pt[zIndex]; + if (pt[zIndex] > zmax) zmax = pt[zIndex]; + } + if (mIndex >= 0 && pt.length > mIndex) { + anyM = true; + if (pt[mIndex] < mmin) mmin = pt[mIndex]; + if (pt[mIndex] > mmax) mmax = pt[mIndex]; + } }; if (hasValue(geometry?.rings)) { @@ -74,11 +93,22 @@ function calculateGeometryExtent(geometry: any): any { return null; } - return { + let extent: any = { type: 'extent', xmin, ymin, xmax, ymax, spatialReference: geometry?.spatialReference ?? null }; + if (anyZ) { + extent.zmin = zmin; + extent.zmax = zmax; + extent.hasZ = true; + } + if (anyM) { + extent.mmin = mmin; + extent.mmax = mmax; + extent.hasM = true; + } + return extent; } export function buildJsGeometry(geometry: any): any { diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs index 5e3a1f094..be3c0830f 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs @@ -60,4 +60,58 @@ public async Task GetExtentCalculatesMissingExtentForPolyline() Assert.AreEqual(-3, extent.Ymin); Assert.AreEqual(9, extent.Ymax); } + + [TestMethod] + public async Task GetExtentCalculatesZBoundsForPolygonWithZ() + { + // For Z-enabled geometries, the calculated extent must also carry zmin/zmax + // (coordinate index 2 when hasZ). + Polygon polygon = new( + [ + new MapPath( + new MapPoint(0, 0, 5), + new MapPoint(0, 10, 15), + new MapPoint(20, 10, 25), + new MapPoint(20, 0, 10), + new MapPoint(0, 0, 5)) + ], + new SpatialReference(102100), + hasZ: true); + + Extent? extent = await polygon.GetExtent(); + + Assert.IsNotNull(extent); + Assert.AreEqual(0, extent!.Xmin); + Assert.AreEqual(20, extent.Xmax); + Assert.AreEqual(0, extent.Ymin); + Assert.AreEqual(10, extent.Ymax); + Assert.AreEqual(5, extent.Zmin); + Assert.AreEqual(25, extent.Zmax); + Assert.IsTrue(extent.HasZ.GetValueOrDefault()); + } + + [TestMethod] + public async Task GetExtentCalculatesMBoundsForPolylineWithM() + { + // For M-enabled (but not Z-enabled) geometries, M is at coordinate index 2 and + // the calculated extent must carry mmin/mmax. + Polyline polyline = new( + [ + new MapPath( + new MapPoint(-5, 2, 100), + new MapPoint(15, -3, 250), + new MapPoint(7, 9, 175)) + ], + new SpatialReference(102100), + hasM: true); + + Extent? extent = await polyline.GetExtent(); + + Assert.IsNotNull(extent); + Assert.AreEqual(-5, extent!.Xmin); + Assert.AreEqual(15, extent.Xmax); + Assert.AreEqual(100, extent.Mmin); + Assert.AreEqual(250, extent.Mmax); + Assert.IsTrue(extent.HasM.GetValueOrDefault()); + } } From ebe16be3879b963712aef98506d634d847fceb6f Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:05:35 +0000 Subject: [PATCH 09/11] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6c4cc3145..98d587ab2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.2.3 + 4.5.2.4 Debug;Release;SourceGen Highlighting AnyCPU true From 0f42a874ec294fed017ea9430b6c7ca80b9d304b Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 19 Jun 2026 13:50:33 -0400 Subject: [PATCH 10/11] Compute missing geometry extent via ArcGIS instead of C# logic Per review: rather than calculating the bounding box in C# (CalculateExtent/CalculateExtentFromPaths) or by hand in TypeScript, buildDotNetGeometry now obtains the extent from ArcGIS. When an operator result (e.g. unionOperator) doesn't expose a cached .extent, it rebuilds a typed ArcGIS geometry via buildJsGeometry so the SDK recomputes it (including Z/M bounds). Reverts the C# extent calculation and its Polygon/Polyline overrides, and removes the now-obsolete C# GeometryExtentTests (the calculation is JS-only; covered by GeometryExtentSerializationTests and the browser TestUnion assertions). Co-Authored-By: Claude Opus 4.8 --- .../Components/Geometries/Geometry.cs | 108 ++-------------- .../Components/Geometries/Polygon.cs | 4 - .../Components/Geometries/Polyline.cs | 4 - .../Scripts/geometry.ts | 81 ++---------- .../GeometryExtentTests.cs | 117 ------------------ 5 files changed, 22 insertions(+), 292 deletions(-) delete mode 100644 test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs index 7b4acbe7f..5425cde14 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Geometry.cs @@ -111,114 +111,24 @@ public override void ValidateRequiredChildren() /// public async Task GetExtent() { - if (CoreJsModule is not null) + if (CoreJsModule is null) { - JsComponentReference ??= await CoreJsModule.InvokeAsync("getJsComponent", CancellationTokenSource.Token, Id); - - if (JsComponentReference is not null) - { - // get the property value - Extent? jsExtent = await CoreJsModule!.InvokeAsync("getProperty", CancellationTokenSource.Token, JsComponentReference, "extent"); - - if (jsExtent is not null) - { -#pragma warning disable BL0005 - Extent = jsExtent; -#pragma warning restore BL0005 - } - } + return Extent; } - // Fall back to calculating the extent client-side when neither the cached value - // nor the JS component provided one (e.g. geometries created in C#, or returned - // from operators that do not expose a cached extent). - if (Extent is null) + JsComponentReference ??= await CoreJsModule.InvokeAsync("getJsComponent", CancellationTokenSource.Token, Id); + if (JsComponentReference is null) { -#pragma warning disable BL0005 - Extent = CalculateExtent(); -#pragma warning restore BL0005 + return Extent; } + // get the property value +#pragma warning disable BL0005 + Extent = await CoreJsModule!.InvokeAsync("getProperty", CancellationTokenSource.Token, JsComponentReference, "extent"); +#pragma warning restore BL0005 return Extent; } - /// - /// Calculates the (bounding box) of this geometry from its - /// coordinates. Returns the existing by default; vertex-based - /// geometries (e.g. , ) override this to - /// compute a missing extent. - /// - [CodeGenerationIgnore] - protected virtual Extent? CalculateExtent() => Extent; - - /// - /// Computes a bounding-box over a set of vertex paths/rings, - /// or when there are no coordinates. When the geometry has - /// Z and/or M values, the resulting extent's Z/M bounds are populated as well. - /// - [CodeGenerationIgnore] - protected static Extent? CalculateExtentFromPaths(IEnumerable? paths, - SpatialReference? spatialReference, bool? hasZ = null, bool? hasM = null) - { - if (paths is null) - { - return null; - } - - // Coordinates are [x, y, z?, m?]: z is at index 2 when hasZ; m is at index 3 - // when hasZ, otherwise index 2. - int zIndex = hasZ == true ? 2 : -1; - int mIndex = hasM == true ? (hasZ == true ? 3 : 2) : -1; - - double xmin = double.MaxValue, ymin = double.MaxValue; - double xmax = double.MinValue, ymax = double.MinValue; - double zmin = double.MaxValue, zmax = double.MinValue; - double mmin = double.MaxValue, mmax = double.MinValue; - bool any = false, anyZ = false, anyM = false; - - foreach (MapPath path in paths) - { - foreach (MapPoint point in path) - { - if (point.Count < 2) - { - continue; - } - - any = true; - if (point[0] < xmin) xmin = point[0]; - if (point[0] > xmax) xmax = point[0]; - if (point[1] < ymin) ymin = point[1]; - if (point[1] > ymax) ymax = point[1]; - - if (zIndex >= 0 && point.Count > zIndex) - { - anyZ = true; - if (point[zIndex] < zmin) zmin = point[zIndex]; - if (point[zIndex] > zmax) zmax = point[zIndex]; - } - - if (mIndex >= 0 && point.Count > mIndex) - { - anyM = true; - if (point[mIndex] < mmin) mmin = point[mIndex]; - if (point[mIndex] > mmax) mmax = point[mIndex]; - } - } - } - - if (!any) - { - return null; - } - - return new Extent(xmax, xmin, ymax, ymin, - zmax: anyZ ? zmax : null, zmin: anyZ ? zmin : null, - mmax: anyM ? mmax : null, mmin: anyM ? mmin : null, - spatialReference: spatialReference, - hasM: anyM ? true : hasM, hasZ: anyZ ? true : hasZ); - } - internal abstract GeometrySerializationRecord ToSerializationRecord(); } diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs index 671f2786a..181c58ef4 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs @@ -107,10 +107,6 @@ public Polygon( #endregion - /// - [CodeGenerationIgnore] - protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Rings, SpatialReference, HasZ, HasM); - /// /// Returns a deep clone of the geometry. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs index 03276583a..d6d656e86 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polyline.cs @@ -23,10 +23,6 @@ public partial class Polyline : Geometry /// public override GeometryType Type => GeometryType.Polyline; - /// - [CodeGenerationIgnore] - protected override Extent? CalculateExtent() => CalculateExtentFromPaths(Paths, SpatialReference, HasZ, HasM); - /// /// Returns a deep clone of the geometry. /// diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts index 00ce5d2cc..bd6af374f 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts @@ -31,84 +31,29 @@ export function buildDotNetGeometry(geometry: any): any { return undefined; } - // Geometries returned from the operator API (e.g. unionOperator) do not always - // expose a cached `extent`, which previously left the .NET Geometry.Extent null - // (breaking e.g. view.GoTo(extent)). Always populate a missing extent, computing - // it from the geometry's coordinates when the geometry does not provide one. + // Geometries returned from the operator API (e.g. unionOperator) do not always expose + // a cached `extent`, which previously left the .NET Geometry.Extent null (breaking + // e.g. view.GoTo(extent)). Use ArcGIS to compute a missing extent. if (hasValue(dotNetGeometry) && !hasValue(dotNetGeometry.extent)) { - let calculatedExtent = calculateGeometryExtent(geometry); - if (hasValue(calculatedExtent)) { - dotNetGeometry.extent = buildDotNetExtent(calculatedExtent); + let jsExtent = getArcGisExtent(geometry, dotNetGeometry); + if (hasValue(jsExtent)) { + dotNetGeometry.extent = buildDotNetExtent(jsExtent); } } return dotNetGeometry; } -// Returns an extent-like object ({ xmin, ymin, xmax, ymax, spatialReference }) for a -// vertex-based geometry (polygon/polyline/multipoint). Prefers the geometry's own -// (lazily computed) extent and falls back to a bounding box over its raw coordinates. -function calculateGeometryExtent(geometry: any): any { +// Returns the ArcGIS-computed extent for a geometry. Prefers the geometry's own `.extent` +// (computed lazily by the SDK, including Z/M bounds); when an operator result doesn't +// expose one, rebuilds a typed ArcGIS geometry from the same coordinates so the SDK +// recomputes it, rather than calculating the bounding box by hand. +function getArcGisExtent(geometry: any, dotNetGeometry: any): any { if (hasValue(geometry?.extent)) { return geometry.extent; } - - // Coordinates are [x, y, z?, m?]: z is at index 2 when hasZ; m is at index 3 when - // hasZ, otherwise index 2. - let hasZ = geometry?.hasZ === true; - let hasM = geometry?.hasM === true; - let zIndex = hasZ ? 2 : -1; - let mIndex = hasM ? (hasZ ? 3 : 2) : -1; - - let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; - let zmin = Infinity, zmax = -Infinity, mmin = Infinity, mmax = -Infinity; - let anyZ = false, anyM = false; - let consider = (pt: Array) => { - if (!hasValue(pt) || pt.length < 2) return; - if (pt[0] < xmin) xmin = pt[0]; - if (pt[0] > xmax) xmax = pt[0]; - if (pt[1] < ymin) ymin = pt[1]; - if (pt[1] > ymax) ymax = pt[1]; - if (zIndex >= 0 && pt.length > zIndex) { - anyZ = true; - if (pt[zIndex] < zmin) zmin = pt[zIndex]; - if (pt[zIndex] > zmax) zmax = pt[zIndex]; - } - if (mIndex >= 0 && pt.length > mIndex) { - anyM = true; - if (pt[mIndex] < mmin) mmin = pt[mIndex]; - if (pt[mIndex] > mmax) mmax = pt[mIndex]; - } - }; - - if (hasValue(geometry?.rings)) { - for (let ring of geometry.rings) for (let pt of ring) consider(pt); - } else if (hasValue(geometry?.paths)) { - for (let path of geometry.paths) for (let pt of path) consider(pt); - } else if (hasValue(geometry?.points)) { - for (let pt of geometry.points) consider(pt); - } - - if (xmin === Infinity) { - return null; - } - - let extent: any = { - type: 'extent', - xmin, ymin, xmax, ymax, - spatialReference: geometry?.spatialReference ?? null - }; - if (anyZ) { - extent.zmin = zmin; - extent.zmax = zmax; - extent.hasZ = true; - } - if (anyM) { - extent.mmin = mmin; - extent.mmax = mmax; - extent.hasM = true; - } - return extent; + let rebuilt = buildJsGeometry(dotNetGeometry); + return rebuilt?.extent ?? null; } export function buildJsGeometry(geometry: any): any { diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs deleted file mode 100644 index be3c0830f..000000000 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/GeometryExtentTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using dymaptic.GeoBlazor.Core.Components; -using dymaptic.GeoBlazor.Core.Components.Geometries; -using dymaptic.GeoBlazor.Core.Model; - - -namespace dymaptic.GeoBlazor.Core.Test.Unit; - -/// -/// Regression tests for the client-side extent calculation added to . -/// The customer reported (against 4.5.1) that a geometry without a cached extent (e.g. a -/// GeometryEngine.Union result) returned a null Extent, breaking map.GoTo(extent). -/// With no CoreJsModule attached, GetExtent() must fall back to calculating the -/// bounding box from the geometry's own coordinates. -/// -[TestClass] -public class GeometryExtentTests -{ - [TestMethod] - public async Task GetExtentCalculatesMissingExtentForPolygon() - { - Polygon polygon = new( - [ - new MapPath( - new MapPoint(0, 0), - new MapPoint(0, 10), - new MapPoint(20, 10), - new MapPoint(20, 0), - new MapPoint(0, 0)) - ], - new SpatialReference(102100)); - - Assert.IsNull(polygon.Extent); - - Extent? extent = await polygon.GetExtent(); - - Assert.IsNotNull(extent); - Assert.AreEqual(0, extent!.Xmin); - Assert.AreEqual(20, extent.Xmax); - Assert.AreEqual(0, extent.Ymin); - Assert.AreEqual(10, extent.Ymax); - } - - [TestMethod] - public async Task GetExtentCalculatesMissingExtentForPolyline() - { - Polyline polyline = new( - [ - new MapPath( - new MapPoint(-5, 2), - new MapPoint(15, -3), - new MapPoint(7, 9)) - ], - new SpatialReference(102100)); - - Extent? extent = await polyline.GetExtent(); - - Assert.IsNotNull(extent); - Assert.AreEqual(-5, extent!.Xmin); - Assert.AreEqual(15, extent.Xmax); - Assert.AreEqual(-3, extent.Ymin); - Assert.AreEqual(9, extent.Ymax); - } - - [TestMethod] - public async Task GetExtentCalculatesZBoundsForPolygonWithZ() - { - // For Z-enabled geometries, the calculated extent must also carry zmin/zmax - // (coordinate index 2 when hasZ). - Polygon polygon = new( - [ - new MapPath( - new MapPoint(0, 0, 5), - new MapPoint(0, 10, 15), - new MapPoint(20, 10, 25), - new MapPoint(20, 0, 10), - new MapPoint(0, 0, 5)) - ], - new SpatialReference(102100), - hasZ: true); - - Extent? extent = await polygon.GetExtent(); - - Assert.IsNotNull(extent); - Assert.AreEqual(0, extent!.Xmin); - Assert.AreEqual(20, extent.Xmax); - Assert.AreEqual(0, extent.Ymin); - Assert.AreEqual(10, extent.Ymax); - Assert.AreEqual(5, extent.Zmin); - Assert.AreEqual(25, extent.Zmax); - Assert.IsTrue(extent.HasZ.GetValueOrDefault()); - } - - [TestMethod] - public async Task GetExtentCalculatesMBoundsForPolylineWithM() - { - // For M-enabled (but not Z-enabled) geometries, M is at coordinate index 2 and - // the calculated extent must carry mmin/mmax. - Polyline polyline = new( - [ - new MapPath( - new MapPoint(-5, 2, 100), - new MapPoint(15, -3, 250), - new MapPoint(7, 9, 175)) - ], - new SpatialReference(102100), - hasM: true); - - Extent? extent = await polyline.GetExtent(); - - Assert.IsNotNull(extent); - Assert.AreEqual(-5, extent!.Xmin); - Assert.AreEqual(15, extent.Xmax); - Assert.AreEqual(100, extent.Mmin); - Assert.AreEqual(250, extent.Mmax); - Assert.IsTrue(extent.HasM.GetValueOrDefault()); - } -} From c000659210e4d468a68af07add11dbab068b3569 Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:56:43 +0000 Subject: [PATCH 11/11] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 98d587ab2..9ef8b5cd5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.2.4 + 4.5.2.5 Debug;Release;SourceGen Highlighting AnyCPU true