Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ Early. The core currently includes GDAL setup and OGR-backed vector dataset
and layer access, including feature and field reading and updates. Available
formats depend on the GDAL drivers enabled in the build; tests cover shapefiles
and GeoPackage. The ActiveX layer supports COM activation, vector loading,
polygon rendering, and asynchronous generic GDAL algorithms with progress and
cancellation events.
polygon rendering, basic map navigation, and asynchronous generic GDAL
algorithms with progress and cancellation events.

The core also supports a shared map viewport with pan, center zoom, anchored
zoom, and fitting to the visible map or a named layer. These navigation calls
do not depend on a window. ActiveX mouse navigation remains future work.
do not depend on a window. ActiveX exposes map/layer zoom operations and
supports cursor-anchored mouse-wheel zoom and left-button drag-to-pan.

Vector loads add layers rather than replace them. Call `ClearLayers` to
release the loaded datasets before switching or refreshing them.
Vector loads add layers rather than replace them. The first layer fits the
initial view; later loads preserve it. Call `ZoomToMap` to include new layers
in the visible extent, or `ClearLayers` to release loaded datasets before
switching or refreshing them.

## Build and test

Expand Down
36 changes: 22 additions & 14 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,10 @@ intentional compatibility gaps and the rationale when interfaces are introduced.
## Current limits and next work

The control exposes a small v6 COM surface: Core initialization, vector
dataset loading, layer-loaded state, feature count, renderer preference,
active backend and status, plus asynchronous generic GDAL operations with
dataset loading, layer-loaded state, feature count, layer count and stable IDs,
layer removal, clear and visibility, map/layer zoom, and center zoom in/out.
Mouse-wheel zoom and drag-to-pan use the same `MapView`. The control also
supports renderer preferences and asynchronous generic GDAL operations with
progress, cancellation, and result reporting. The GDAL API has a new interface
and does not claim MapWinGIS 5 `IGdalUtils` binary compatibility.
At most sixteen operations may be active or awaiting result cleanup. Call
Expand All @@ -348,12 +350,19 @@ polygonal. An empty polygon layer loads but draws nothing. The control reads
and validates the candidate layer before adding it, so a rejected load cannot
change the live viewport or schedule a fit.
`LayerLoaded` reports whether the map has any layer, and `FeatureCount` is
the cached total over all loaded layers. The control updates the total after
a successful load and resets it on `ClearLayers`. Property reads do not scan
datasets or rebuild the scene. Until the control exposes navigation, every load
fits the view to the whole map. Raster layers and other geometry types are
not rendered. There is no reprojection, so coordinates remain in the source
coordinate system and layers are assumed to share one.
the cached total over all loaded layers. The control updates the total after a
successful load or removal and resets it on `ClearLayers`; property reads do
not scan datasets or rebuild the scene. The first layer fits the initial view;
later loads preserve the current view, and callers can use `ZoomToMap` to
include new layers in the visible extent. `IMapWinGISControl4` exposes layer
count and stable layer IDs, removal, visibility, `ZoomToMap`, `ZoomToLayer`,
`ZoomIn`, and `ZoomOut`; it inherits `ClearLayers` from `IMapWinGISControl3`.
Layer reordering is not exposed because Core has no reorder operation.
Mouse-wheel zoom is anchored at the cursor. Left-button dragging pans the
view, with windowless mouse capture released on button-up, capture loss,
cancellation, or control destruction. Raster layers and other geometry types
are not rendered. There is no reprojection, so coordinates remain in the
source coordinate system and layers are assumed to share one.

Core prepares and retains the complete supported polygon scene in memory,
reading feature geometries sequentially without WKT serialization and
Expand All @@ -370,10 +379,9 @@ Windows CPU fallback; Core has no independent platform-neutral renderer.

The C# host's pixel assertions, activation, and native tests pass for Debug
and Release on Win32 and x64 in an interactive desktop session.
Core navigation on the shared `MapView` (zoom to map and layer, zoom in and
out, anchored zoom, and pan) is implemented but not yet exposed through COM or
mouse input; that is the next milestone. Deep zoom can push a layer past the
GDI coordinate range until clipping exists. Measure scene preparation with
representative datasets before adding caching, paging, or broader OpenGL
geometry support.
Core and ActiveX navigation use the shared `MapView`; the COM API exposes
programmatic zoom operations and the control handles mouse-wheel zoom and
drag-to-pan. Deep zoom can push a layer past the GDI coordinate range until
clipping exists. Measure scene preparation with representative datasets
before adding caching, paging, or broader OpenGL geometry support.
OpenCL and full MapWinGIS 5 API parity remain deferred.
10 changes: 6 additions & 4 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ pwsh -File samples\CSharp\ActiveXTestHost\Run-ActiveXTestHost.ps1 -Configuration

The launcher makes no COM registration changes. Automated mode creates a
visible window and runs the UI message loop, checks the `CoreInitialized`
automation property, loads a generated two-polygon GeoJSON dataset through the
control, resizes the host, samples rendered polygon and background pixels for
both the GDI preference and the default (OpenGL-first) path, recreates the
control, reloads the layer, and writes individual outcomes to
automation property, loads generated GeoJSON layers through the control,
checks the COM zoom methods and stable layer IDs, sends cursor-anchored wheel
zoom and captured drag-to-pan input, resizes the host, samples rendered
polygon and background pixels for both the GDI preference and the default
(OpenGL-first) path, recreates the control, reloads the layer, and writes
individual outcomes to
`bin\Debug\x64\ActiveXTestHost-results.json`. Pixels are sampled from the host
window's own composed content (`PrintWindow` with `PW_RENDERFULLCONTENT`), not
from the screen, so another window on top or a lost foreground race does not
Expand Down
238 changes: 238 additions & 0 deletions samples/CSharp/ActiveXTestHost/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ internal sealed class MainForm : Form
private readonly string _testDatasetDirectory =
Path.Combine(Path.GetTempPath(), $"MapWinGIS.ActiveXTestHost-{Guid.NewGuid():N}");
private string TestDatasetPath => Path.Combine(_testDatasetDirectory, "sample.geojson");
private string TestSecondDatasetPath => Path.Combine(_testDatasetDirectory, "second.geojson");
private string TestRasterPath => Path.Combine(_testDatasetDirectory, "sample.asc");
private string TestRasterOutputPath => Path.Combine(_testDatasetDirectory, "sample.tif");
private ActiveXHostControl? _host;
Expand Down Expand Up @@ -152,6 +153,8 @@ private async Task RunAutomatedAsync()
_statusLabel.Text = $"Active: {ProgId}; CoreInitialized={initialized}";
});
RecordCheck("Load vector layer", LoadTestLayer);
RecordCheck("Programmatic map navigation", VerifyProgrammaticNavigation);
await RecordCheckAsync("Mouse-wheel zoom and drag-to-pan", VerifyMouseNavigationAsync);
await RecordCheckAsync("GDAL COM event sinks", VerifyGdalEventSinksAsync);
RecordCheck("CPU backend preference and status", VerifyCpuBackendSelection);

Expand Down Expand Up @@ -223,6 +226,12 @@ private void VerifyPrerequisites()
"The generated vector test dataset is missing.",
TestDatasetPath);
}
if (!File.Exists(TestSecondDatasetPath))
{
throw new FileNotFoundException(
"The generated second vector test dataset is missing.",
TestSecondDatasetPath);
}
}

private void CreateTestDataset()
Expand Down Expand Up @@ -255,6 +264,33 @@ private void CreateTestDataset()
]
}
""");
File.WriteAllText(
TestSecondDatasetPath,
"""
{
"type": "FeatureCollection",
"name": "second",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{
"type": "Feature",
"properties": { "id": 3, "name": "west" },
"geometry": {
"type": "Polygon",
"coordinates": [[[100, 0], [101.5, 0], [101.5, 2], [100, 2], [100, 0]]]
}
},
{
"type": "Feature",
"properties": { "id": 4, "name": "east" },
"geometry": {
"type": "Polygon",
"coordinates": [[[102.5, 0], [104, 0], [104, 2], [102.5, 2], [102.5, 0]]]
}
}
]
}
""");

var raster = new StringBuilder();
raster.AppendLine("ncols 512");
Expand Down Expand Up @@ -334,6 +370,68 @@ private void CreateHostedControl()
Application.DoEvents();
}

private async Task WaitForRepaintAsync()
{
RepaintHost();
await Task.Delay(150);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixed 150 ms delay plus Application.DoEvents makes the repaint wait timing-dependent: on a slow or heavily loaded desktop session the repaint may not be complete when the capture runs, and DoEvents pumps unrelated messages while waiting. Consider polling the captured frame until it is stable (or waiting on an explicit rendered-frame signal) instead of a hard-coded sleep.

Application.DoEvents();
}

private void SendMouseWheel(Point clientPosition, short delta)
{
if (_host is null)
{
throw new InvalidOperationException("The ActiveX control has not been created.");
}

Point screenPosition = _host.PointToScreen(clientPosition);
IntPtr wParam = new(unchecked((int)((ushort)delta << 16)));
SendMessage(
_host.Handle,
MouseWheelMessage,
wParam,
PackPosition(screenPosition.X, screenPosition.Y));
}

private void SendMouseMessage(uint message, nint buttonState, Point clientPosition)
{
if (_host is null)
{
throw new InvalidOperationException("The ActiveX control has not been created.");
}

SendMessage(
_host.Handle,
message,
buttonState,
PackPosition(clientPosition.X, clientPosition.Y));
}

private static void VerifyImagesDiffer(Bitmap before, Bitmap after, string operation)
{
if (before.Width != after.Width || before.Height != after.Height)
{
throw new InvalidOperationException($"The host image size changed during {operation}.");
}

int changedPixels = 0;
for (int y = 0; y < before.Height; y++)
{
for (int x = 0; x < before.Width; x++)
{
if (before.GetPixel(x, y).ToArgb() != after.GetPixel(x, y).ToArgb())
{
changedPixels++;
}
}
}
if (changedPixels < 100)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two robustness concerns with this check: (1) if a wheel step lands exactly at the resolution limit, Core's ZoomAt correctly returns changed = false and the frame is identical — changedPixels will be 0 and this throws, turning a legitimate clamp into a test failure; (2) the comparison covers the entire captured window, including any non-map chrome, so an unrelated pixel change could also satisfy the threshold. Consider gating on the operation having reported a change (or comparing only the map client area) rather than a raw 100-pixel floor.

{
throw new InvalidOperationException(
$"The {operation} did not change the rendered map enough to verify navigation.");
}
}

private bool ReadCoreInitialized()
{
if (_host is null)
Expand Down Expand Up @@ -364,6 +462,136 @@ private void LoadTestLayer()
VerifyCoreInitialized("vector dataset load");
}

private void VerifyProgrammaticNavigation()
{
if (_host?.AutomationObject is not object automationObject)
{
throw new InvalidOperationException("The ActiveX automation object is unavailable.");
}

dynamic automation = automationObject;
automation.LoadVectorDataset(TestSecondDatasetPath);
if ((int)automation.LayerCount != 2 || (long)automation.FeatureCount != 4)
{
throw new InvalidOperationException(
"Loading another dataset did not add its layer while preserving the map.");
}

long firstLayerId = (long)automation.GetLayerId(0);
long secondLayerId = (long)automation.GetLayerId(1);
if (firstLayerId <= 0 || secondLayerId <= 0 || firstLayerId == secondLayerId)
{
throw new InvalidOperationException("The map did not expose distinct stable layer IDs.");
}

if (!(bool)automation.ZoomToMap() ||
!(bool)automation.ZoomToLayer(secondLayerId) ||
(bool)automation.ZoomToLayer(secondLayerId + 1000) ||
!(bool)automation.ZoomToLayer(firstLayerId) ||
!(bool)automation.ZoomIn() ||
!(bool)automation.ZoomOut())
{
throw new InvalidOperationException(
"The control's zoom-to-map, zoom-to-layer, zoom-in, or zoom-out operation failed.");
}

if (!(bool)automation.ZoomToMap() ||
!(bool)automation.SetLayerVisible(secondLayerId, false) ||
!(bool)automation.ZoomToMap() ||
!(bool)automation.SetLayerVisible(secondLayerId, true) ||
!(bool)automation.ZoomToMap() ||
!(bool)automation.ZoomToLayer(firstLayerId) ||
!(bool)automation.RemoveLayer(secondLayerId) ||
(int)automation.LayerCount != 1 ||
(long)automation.FeatureCount != 2 ||
(long)automation.GetLayerId(0) != firstLayerId)
{
throw new InvalidOperationException(
"The control's layer visibility or removal operation failed.");
}

automation.ClearLayers();
if ((int)automation.LayerCount != 0 ||
(long)automation.FeatureCount != 0 ||
(bool)automation.ZoomToMap())
{
throw new InvalidOperationException("The control did not clear its layer collection.");
}

automation.LoadVectorDataset(TestDatasetPath);
automation.LoadVectorDataset(TestSecondDatasetPath);
if ((int)automation.LayerCount != 2 || !(bool)automation.ZoomToMap())
{
throw new InvalidOperationException(
"The control did not restore its layers after clearing the map.");
}
}

private async Task VerifyMouseNavigationAsync()
{
if (_host?.AutomationObject is not object automationObject)
{
throw new InvalidOperationException("The ActiveX automation object is unavailable.");
}

dynamic automation = automationObject;
long firstLayerId = (long)automation.GetLayerId(0);

automation.ZoomToMap();
if (!(bool)automation.ZoomToLayer(firstLayerId))
{
throw new InvalidOperationException("Could not reset the view before testing mouse input.");
}
RepaintHost();
using (Bitmap beforeWheel = CaptureWindowContent())
{
SendMouseWheel(new Point(_host.Width / 5, _host.Height / 2), 240);
await WaitForRepaintAsync();
using Bitmap afterWheel = CaptureWindowContent();
VerifyImagesDiffer(beforeWheel, afterWheel, "centered mouse-wheel zoom");
}

automation.ZoomToMap();
if (!(bool)automation.ZoomToLayer(firstLayerId))
{
throw new InvalidOperationException("Could not reset the view before testing edge zoom.");
}
RepaintHost();
using (Bitmap beforeEdgeWheel = CaptureWindowContent())
{
SendMouseWheel(new Point(_host.Width * 4 / 5, _host.Height / 2), -120);
await WaitForRepaintAsync();
using Bitmap afterEdgeWheel = CaptureWindowContent();
VerifyImagesDiffer(beforeEdgeWheel, afterEdgeWheel, "near-edge mouse-wheel zoom");
}

automation.ZoomToMap();
if (!(bool)automation.ZoomToLayer(firstLayerId))
{
throw new InvalidOperationException("Could not reset the view before testing drag-to-pan.");
}
RepaintHost();
int dragDistance = Math.Max(40, _host.Width / 8);
Point start = new(_host.Width / 2, _host.Height / 2);
Point end = new(start.X + dragDistance, start.Y);
SendMouseMessage(LeftButtonDown, 1, start);
SendMouseMessage(MouseMoveMessage, 1, end);
SendMouseMessage(LeftButtonUp, 0, new Point(-8, -8));
await WaitForRepaintAsync();

using Bitmap afterPan = CaptureWindowContent();
VerifyPaintPixel(
afterPan,
_host.PointToScreen(new Point(_host.Width / 5 + dragDistance, _host.Height / 2)),
ColorToColorRef(MapFillColor),
"panned polygon");
if (!(bool)automation.ZoomToLayer(firstLayerId))
{
throw new InvalidOperationException("Could not restore the view after testing drag-to-pan.");
}
RepaintHost();
}

private void VerifyCoreInitialized(string activation)
{
if (!ReadCoreInitialized())
Expand Down Expand Up @@ -745,6 +973,13 @@ protected override void Dispose(bool disposing)
private static uint ColorToColorRef(Color color) =>
(uint)(color.R | (color.G << 8) | (color.B << 16));

private static IntPtr PackPosition(int x, int y) =>
new(unchecked((int)((ushort)(short)x | ((uint)(ushort)(short)y << 16))));

private const uint MouseWheelMessage = 0x020A;
private const uint LeftButtonDown = 0x0201;
private const uint MouseMoveMessage = 0x0200;
private const uint LeftButtonUp = 0x0202;
private const uint PrintWindowRenderFullContent = 0x00000002;

[StructLayout(LayoutKind.Sequential)]
Expand All @@ -767,6 +1002,9 @@ private struct NativeRect
[DllImport("user32.dll")]
private static extern uint GetSysColor(int index);

[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr window, uint message, nint wParam, nint lParam);

private sealed record CheckResult(string Check, bool Passed, string Details);
private sealed record TestReport(bool Automated, int ExitCode, IReadOnlyList<CheckResult> Checks);
}
Loading