using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using MudBlazor; using RobotNet10.RobotApp.Client.Clients; using RobotNet10.RobotApp.Client.Shared.SLAM; using RobotNet10.Shared.Geometry; using RobotNet10.Shared.Numbers; using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion; using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion; namespace RobotNet10.RobotApp.Client.Components.SLAM; public partial class MapEdit { private const double MinFitScale = 0.5; [Parameter] public string MapName { get; set; } = string.Empty; [Inject] private SLAMClient SLAMClient { get; set; } = null!; private IJSObjectReference _jsModule = null!; private DotNetObjectReference _dotNetObj = null!; // Element references private ElementReference containerRef; private ElementReference viewMovementRef; private ElementReference mapContainerRef; private ElementReference mapImageRef; private MapMousePosition MapMousePositionRef = null!; private GoalPose GoalPoseRef = null!; // State private MapInfoDto MapInfo { get; set; } = new(); private bool IsLoading { get; set; } = true; private bool IsMapLoaded => MapInfo != null && _imageLoaded; private bool _imageLoaded = false; // Map image URL with cache busting private long _imageCacheBuster = DateTime.Now.Ticks; private string MapImageUrl => $"/api/maps/{MapName}/image?v={_imageCacheBuster}"; // Container rect state private double _containerRectX = 0.0; private double _containerRectY = 0.0; private double _containerRectWidth = 0.0; private double _containerRectHeight = 0.0; private double _containerRectTop = 0.0; private double _containerRectRight = 0.0; private double _containerRectBottom = 0.0; private double _containerRectLeft = 0.0; // View state public double CursorX { get; private set; } = 0.0; public double CursorY { get; private set; } = 0.0; private double _clientOriginX = 0.0; private double _clientOriginY = 0.0; private double _scale = 1.0; private double _left = 0.0; private double _top = 0.0; private double _fitScale = 1.0; // Map data private double _resolution = 1.0; private double _originX = 0.0; private double _originY = 0.0; // Transformed origin Y (like MapLocalization) private double _mapOriginY = 0.0; // Original origin Y from map (like MapLocalization._mapOriginY) private double _imageWidth = 0.0; private double _imageHeight = 0.0; private int _imagePixelWidth = 0; private int _imagePixelHeight = 0; // SVG viewBox origin (like MapLocalization._svgOriginX and _svgOriginY) private double _svgOriginX = 0.0; private double _svgOriginY = 0.0; #region Lifecycle protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); if (!firstRender) return; // Load JavaScript module _jsModule = await JSRuntime.InvokeAsync("import", "/js/mapLocalization.js"); // Setup event listeners _dotNetObj = DotNetObjectReference.Create(this); await _jsModule.InvokeVoidAsync("updateContainerRect", _dotNetObj, containerRef, nameof(OnContainerResize)); await _jsModule.InvokeVoidAsync("registerResizeObserver", _dotNetObj, containerRef, nameof(OnContainerResize)); await _jsModule.InvokeVoidAsync("addMouseWheelEventListener", _dotNetObj, containerRef, nameof(OnMouseWheel)); await _jsModule.InvokeVoidAsync("addMouseMoveEventListener", _dotNetObj, containerRef, nameof(OnMouseMove)); await _jsModule.InvokeVoidAsync("addMouseDownEventListener", _dotNetObj, containerRef, nameof(OnMouseDown)); await _jsModule.InvokeVoidAsync("addMouseUpEventListener", _dotNetObj, containerRef, nameof(OnMouseUp)); // Start SLAMClient and register event handler await SLAMClient.StartAsync(); SLAMClient.IsProcessingChanged += OnIsProcessingChanged; // Load map info await LoadMapInfoAsync(); await OnImageLoaded(); } private async Task LoadMapInfoAsync() { try { IsLoading = true; StateHasChanged(); MapInfo = await SLAMClient.GetMapInfoAndSubscribeProcessingAsync(MapName) ?? new(); if (!string.IsNullOrEmpty(MapInfo.Name)) { _resolution = MapInfo.Resolution; _imageWidth = MapInfo.Width; _imageHeight = MapInfo.Height; _originX = MapInfo.OriginX; _mapOriginY = MapInfo.OriginY; // Original origin Y from map // Transformed origin Y (like MapLocalization: _originY = -imageHeight - mapOriginY) _originY = -_imageHeight - _mapOriginY; // Calculate pixel dimensions from world dimensions and resolution if (_resolution > 0) { _imagePixelWidth = (int)Math.Round(_imageWidth / _resolution); _imagePixelHeight = (int)Math.Round(_imageHeight / _resolution); } } } catch (Exception ex) { Snackbar.Add($"Failed to load map info: {ex.Message}", MudBlazor.Severity.Error); } finally { IsLoading = false; StateHasChanged(); } } private void OnIsProcessingChanged(string mapName, bool isProcessing) { if (mapName == MapName) { var wasProcessing = MapInfo.IsProcessing; MapInfo.IsProcessing = isProcessing; // When processing completes (true -> false), reload map info and update image if (wasProcessing && !isProcessing) { InvokeAsync(async () => { // Update cache buster to force image reload _imageCacheBuster = DateTime.Now.Ticks; // Reload map info to get updated data await LoadMapInfoAsync(); // Force reload image with reset CSS styles and get natural dimensions await ReloadImageAsync(); GoalPoseRef.Hide(); }); } else { InvokeAsync(StateHasChanged); } } } private async Task ReloadImageAsync() { try { // Pixel dimensions already calculated in LoadMapInfoAsync from MapInfo _imageLoaded = true; StateHasChanged(); // Wait for DOM update and image reload await Task.Delay(100); // Configure SVG viewBox with world coordinates first await ConfigureSvgViewBoxAsync(); // Then fit to view (scales SVG to pixel coords) await FitViewAsync(); } catch (Exception ex) { Console.Error.WriteLine($"[MapEdit] ReloadImageAsync: Exception - {ex.Message}"); } } private async Task OnImageLoaded() { try { // Pixel dimensions already calculated in LoadMapInfoAsync from MapInfo _imageLoaded = true; StateHasChanged(); // Wait for DOM update and image load await Task.Delay(100); // Configure SVG viewBox with world coordinates first await ConfigureSvgViewBoxAsync(); // Then fit to view (scales SVG to pixel coords) await FitViewAsync(); } catch (Exception ex) { Console.Error.WriteLine($"[MapEdit] OnImageLoaded: Exception - {ex.Message}"); } } #endregion #region View & Scale public async Task FitViewAsync() { if (!IsMapLoaded || _imageWidth <= 0 || _imageHeight <= 0) return; // Recalculate fit scale if (_containerRectWidth > 0 && _containerRectHeight > 0) { _fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight); if (_fitScale < MinFitScale) _fitScale = MinFitScale; } await ScaleFitContentAsync(); } private void UpdateClientOrigin() { if (!IsMapLoaded) return; _clientOriginX = _containerRectLeft + _left - _originX * _scale; _clientOriginY = _containerRectTop + _top - _originY * _scale; } private async Task ScaleFitContentAsync() { if (!IsMapLoaded) return; _scale = _fitScale; var wrapperWidth = _imageWidth * _scale; var wrapperHeight = _imageHeight * _scale; var centerLeft = (_containerRectWidth - wrapperWidth) / 2; var centerTop = (_containerRectHeight - wrapperHeight) / 2; await SetViewMovement(centerLeft, centerTop); // Set SVG rect (pixel coords) and image sizes (like MapLocalization.ScaleFitContentAsync) await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, wrapperWidth, wrapperHeight); await _jsModule.InvokeVoidAsync("setCanvasRect", mapImageRef, wrapperWidth, wrapperHeight); } /// /// Configure SVG viewBox with world coordinates (like MapLocalization.DrawOccupancyGridAsync) /// private async Task ConfigureSvgViewBoxAsync() { if (!IsMapLoaded || _jsModule == null) return; // Update SVG viewBox origin _svgOriginX = _originX; _svgOriginY = _mapOriginY; // Set SVG config with world coordinates for viewBox await _jsModule.InvokeVoidAsync("setSvgConfig", mapContainerRef, _imageWidth, _imageHeight, _svgOriginX, _svgOriginY); } private async Task SetViewMovement(double left, double top) { _top = top; _left = left; UpdateClientOrigin(); if (_jsModule != null && IsMapLoaded) { var width = _imageWidth * _scale; var height = _imageHeight * _scale; await _jsModule.InvokeVoidAsync("setMapMovement", viewMovementRef, _top, _left, width, height); } } #endregion #region Event Handlers (JSInvokable) [JSInvokable] public void OnContainerResize(double x, double y, double width, double height, double top, double right, double bottom, double left) { _containerRectX = x; _containerRectY = y; _containerRectWidth = width; _containerRectHeight = height; _containerRectTop = top; _containerRectRight = right; _containerRectBottom = bottom; _containerRectLeft = left; UpdateClientOrigin(); // Recalculate fit scale if (_imageWidth > 0 && _imageHeight > 0) { _fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight); } } [JSInvokable] public async Task OnMouseWheel(double deltaY, double clientX, double clientY) { if (!IsMapLoaded) return; // Calculate scale change double scaleChange; if (deltaY > 0) { if (_scale <= _fitScale / 2) return; scaleChange = _scale > _fitScale ? -(_scale / _fitScale) : -0.1; } else { if (_scale >= _fitScale * 100) return; scaleChange = _scale < _fitScale ? 0.5 : (_scale / _fitScale); } double oldScale = _scale; _scale += scaleChange; // Set SVG rect (pixel coords) and image sizes (like MapLocalization) await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, _imageWidth * _scale, _imageHeight * _scale); await _jsModule.InvokeVoidAsync("setCanvasRect", mapImageRef, _imageWidth * _scale, _imageHeight * _scale); // Calculate cursor position in world coordinates (exactly like MapLocalization) CursorX = (clientX - _clientOriginX) / oldScale; CursorY = (_clientOriginY - clientY) / oldScale; MapMousePositionRef?.Update(CursorX, CursorY); // Calculate mouse position relative to map origin (exactly like MapLocalization) // MapLocalization: mouseX = CursorX - OriginX // MapLocalization: mouseY = CursorY - MapData.OriginY (use original origin Y, not transformed) double mouseX = CursorX - _originX; double mouseY = CursorY - _mapOriginY; // Calculate movement adjustment (exactly like MapLocalization) await SetViewMovement(_left - mouseX * scaleChange, _top - (_imageHeight - mouseY) * scaleChange); } [JSInvokable] public async Task OnMouseMove(double clientX, double clientY, long buttons, bool ctrlKey, double movementX, double movementY) { // Calculate cursor position in world coordinates CursorX = (clientX - _clientOriginX) / _scale; CursorY = (_clientOriginY - clientY) / _scale; MapMousePositionRef?.Update(CursorX, CursorY); // Left mouse button down: update goal pose to current mouse if (buttons == 1) { GoalPoseRef?.SetMousePosition(CursorX, CursorY); } // Middle mouse button for panning else if (buttons == 4) { await SetViewMovement(_left + movementX, _top + movementY); } } [JSInvokable] public async Task OnMouseDown(int button, bool altKey, bool ctrlKey, bool shiftKey) { if (button == 0) // Left mouse button: set goal pose { // For MapEdit, robot position is at origin (0, 0) since we don't have live robot pose GoalPoseRef?.SetRobotPosition(0, 0); GoalPoseRef?.SetGoalPosition(CursorX, CursorY); GoalPoseRef?.SetMousePosition(CursorX, CursorY); GoalPoseRef?.Show(); } await Task.CompletedTask; } [JSInvokable] public async Task OnMouseUp(int button, bool altKey, bool ctrlKey, bool shiftKey) { if (button == 0) // Left mouse button up: apply Optimize { GoalPoseRef?.Optimize(); } await Task.CompletedTask; } #endregion #region Edit Origin Dialog // Dialog state private bool _editOriginDialogVisible = false; private readonly DialogOptions _editOriginDialogOptions = new() { CloseOnEscapeKey = false, CloseButton = true, BackdropClick = false, MaxWidth = MaxWidth.Small, FullWidth = true }; // Dialog form values private double _editOriginX = 0.0; private double _editOriginY = 0.0; private double _editOriginYaw = 0.0; private bool _editOriginYawUseRadian = true; private void OpenEditOriginDialog() { // Reset to radians when opening dialog (GoalYaw is in radians) _editOriginYawUseRadian = true; if (GoalPoseRef == null) { _editOriginX = MapInfo.OriginX; _editOriginY = MapInfo.OriginY; _editOriginYaw = 0; } else { // Get values from GoalPoseRef _editOriginX = GoalPoseRef.GoalX; _editOriginY = GoalPoseRef.GoalY; _editOriginYaw = GoalPoseRef.GoalYaw; } _editOriginDialogVisible = true; } private void CloseEditOriginDialog() { _editOriginDialogVisible = false; } private void ToggleRadianDegree() { if (_editOriginYawUseRadian) { // Convert from radians to degrees _editOriginYaw = _editOriginYaw * 180.0 / Math.PI; } else { // Convert from degrees to radians _editOriginYaw = _editOriginYaw * Math.PI / 180.0; } _editOriginYawUseRadian = !_editOriginYawUseRadian; } private async Task ConfirmEditOrigin() { try { // Convert yaw to radians if needed var yawRadians = _editOriginYawUseRadian ? _editOriginYaw : _editOriginYaw * Math.PI / 180.0; // Create pose with new origin position and orientation var q = QuaternionNumbers.FromYawRadian(yawRadians); var newOriginPose = new PoseDto { Position = new RobotNet10.Shared.Numbers.Vector3 { X = _editOriginX, Y = _editOriginY, Z = 0 }, Orientation = new QuaternionGeometry(q.X, q.Y, q.Z, q.W), Timestamp = DateTime.UtcNow }; // Call SLAMClient to transform map origin var success = await SLAMClient.TransformMapOriginAsync(MapName, newOriginPose); if (success) { Snackbar.Add($"Origin updated to ({_editOriginX:F3}, {_editOriginY:F3}) with yaw {yawRadians:F3} rad", Severity.Success); _editOriginDialogVisible = false; // Reload map info to reflect changes await LoadMapInfoAsync(); } else { Snackbar.Add("Failed to update map origin", Severity.Error); } } catch (Exception ex) { Snackbar.Add($"Failed to update origin: {ex.Message}", Severity.Error); } } #endregion #region Rerender Map Dialog // Dialog state private bool _rerenderDialogVisible = false; private readonly DialogOptions _rerenderDialogOptions = new() { CloseOnEscapeKey = false, CloseButton = true, BackdropClick = false, MaxWidth = MaxWidth.Medium, FullWidth = true }; // Rerender config private OccupancyGridConfigurationDto _rerenderConfig = new(); private void OpenRerenderDialog() { // Reset to default values _rerenderConfig = new OccupancyGridConfigurationDto(); _rerenderDialogVisible = true; } private void CloseRerenderDialog() { _rerenderDialogVisible = false; } private async Task ConfirmRerender() { try { // Close dialog _rerenderDialogVisible = false; // Call SLAMClient to rerender map var success = await SLAMClient.RerenderMapWithConfigAsync(MapName, _rerenderConfig); if (success) { Snackbar.Add("Map rerender started. Please wait...", Severity.Info); } else { Snackbar.Add("Failed to start map rerender (map may already be processing)", Severity.Error); } } catch (Exception ex) { Snackbar.Add($"Failed to rerender map: {ex.Message}", Severity.Error); } } #endregion #region Dispose public async ValueTask DisposeAsync() { // Unsubscribe from event SLAMClient.IsProcessingChanged -= OnIsProcessingChanged; // Unsubscribe from map processing group try { if (SLAMClient.IsConnected && !string.IsNullOrEmpty(MapName)) { await SLAMClient.UnsubscribeMapProcessingAsync(MapName); } } catch { // Ignore errors during dispose } // Stop SLAMClient try { await SLAMClient.StopAsync(); } catch { // Ignore errors during dispose } if (_jsModule != null) { await _jsModule.DisposeAsync(); } } #endregion }