diff --git a/Directory.Build.props b/Directory.Build.props
index 05d3d47be..9ef8b5cd5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,7 +6,7 @@
enable
enable
true
- 4.5.2
+ 4.5.2.5
Debug;Release;SourceGen Highlighting
AnyCPU
true
diff --git a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs
index 418b8b3fc..181c58ef4 100644
--- a/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs
+++ b/src/dymaptic.GeoBlazor.Core/Components/Geometries/Polygon.cs
@@ -106,7 +106,7 @@ public Polygon(
public override GeometryType Type => GeometryType.Polygon;
#endregion
-
+
///
/// Returns a deep clone of the geometry.
///
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/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts
index 0bb27900e..bd6af374f 100644
--- a/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts
+++ b/src/dymaptic.GeoBlazor.Core/Scripts/geometry.ts
@@ -10,20 +10,50 @@ 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)). Use ArcGIS to compute a missing extent.
+ if (hasValue(dotNetGeometry) && !hasValue(dotNetGeometry.extent)) {
+ let jsExtent = getArcGisExtent(geometry, dotNetGeometry);
+ if (hasValue(jsExtent)) {
+ dotNetGeometry.extent = buildDotNetExtent(jsExtent);
+ }
+ }
+
+ return dotNetGeometry;
+}
+
+// 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;
+ }
+ let rebuilt = buildJsGeometry(dotNetGeometry);
+ return rebuilt?.extent ?? 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 471c1c90c..175a47f47 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]
@@ -1935,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/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);
+ }
+}
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..7fb317d7e
--- /dev/null
+++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/SnappingOptionsApiTests.cs
@@ -0,0 +1,125 @@
+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 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")]
+ [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(FeatureSnappingLayerSource[])]);
+
+ Assert.IsNotNull(method,
+ $"SnappingOptions.{methodName}(params FeatureSnappingLayerSource[]) must exist (regression from 4.5.x).");
+ Assert.AreEqual(typeof(Task), method.ReturnType,
+ $"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(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 async Task Setters_UpdateLocalProperty_BeforeRender()
+ {
+ // 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();
+
+ await options.SetEnabled(true);
+ Assert.IsTrue(options.Enabled.GetValueOrDefault(), "SetEnabled should update the local Enabled value.");
+
+ await options.SetGridEnabled(true);
+ Assert.IsTrue(options.GridEnabled.GetValueOrDefault(), "SetGridEnabled should update the local GridEnabled value.");
+ }
+}