Files
XplorePlane/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml.cs
T
2026-08-11 16:34:22 +08:00

643 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Prism.Ioc;
using Serilog;
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using XplorePlane.Services.Hardware.Camera;
using XplorePlane.Services.Hardware.Coordinate;
using XplorePlane.Views.Main.Overlays;
namespace XplorePlane.Views.Main
{
public partial class NavigationPropertyPanelView : UserControl
{
private static readonly ILogger _diag = Log.ForContext<NavigationPropertyPanelView>();
private NavigationPropertyPanelViewModel? _viewModel;
private InspectionMarkOverlay? _navMarkOverlay;
private IViewCoordinateMapper? _mapper;
// —— 青框画中画状态 ——
/// <summary>从原图裁切的正方形边长(像素),固定不变</summary>
private double _roiSizePx = 60;
/// <summary>屏幕上青色框的显示边长(像素坐标系),随滚轮变化</summary>
private double _roiScreenPx = 150;
/// <summary>当前 ROI 中心(图像像素坐标系)——裁切区域中心</summary>
private Point _roiCenterPx;
/// <summary>青框是否可见</summary>
private bool _pipVisible;
/// <summary>是否处于放大锁定模式(框固定,鼠标在框内精确移动)</summary>
private bool _isZoomedLocked;
/// <summary>放大锁定时,框中心在图像像素坐标系中的固定位置</summary>
private Point _lockedFrameCenter;
/// <summary>放大锁定时,青色十字线对应的精确图像像素位置</summary>
private Point _preciseTargetPx;
// 缩放参数
private const double PipScaleFactor = 1.2;
private const double PipScreenMin = 80;
private const double PipScreenMax = 800;
/// <summary>未放大时的基础框大小(等于 roiSizePx 时为 1:1</summary>
private const double PipScreenBase = 150;
public NavigationPropertyPanelView()
{
InitializeComponent();
ResolveViewModel();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
ResolveViewModel();
AttachMarkOverlay();
// 将导航窗体实际渲染结果注册给 CNC 归档服务,包含标记叠加层。
try
{
var frameProvider = AppBootstrapper.Instance?.Container.Resolve<INavigationCameraFrameProvider>();
frameProvider?.SetCompositeCapture(CaptureCompositeImage);
}
catch (Exception ex)
{
_diag.Warning(ex, "注册导航合成截图回调失败");
}
// 订阅 ViewModel 属性变化,驱动红十字线位置更新
if (_viewModel != null)
{
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
UpdateCrosshair();
// 注入合成截图委托:保存导航图时对相机背景 + 标记叠加层一并截图
_viewModel.CompositeImageProvider = CaptureCompositeImage;
}
}
/// <summary>
/// 将导航图像显示区域(相机背景 + 标记叠加层)渲染为合成位图,
/// 供"保存导航图照片"对用户实际看到的图层进行截图。
/// </summary>
private BitmapSource? CaptureCompositeImage()
{
if (!Dispatcher.CheckAccess())
return Dispatcher.Invoke(CaptureCompositeImage);
var target = cameraDisplayHost;
if (target == null)
return null;
int width = (int)target.ActualWidth;
int height = (int)target.ActualHeight;
if (width <= 0 || height <= 0)
return null;
try
{
var dpi = VisualTreeHelper.GetDpi(target);
var rtb = new RenderTargetBitmap(
(int)(width * dpi.DpiScaleX),
(int)(height * dpi.DpiScaleY),
dpi.PixelsPerInchX,
dpi.PixelsPerInchY,
PixelFormats.Pbgra32);
rtb.Render(target);
rtb.Freeze();
return rtb;
}
catch
{
return null;
}
}
private void ResolveViewModel()
{
if (DataContext is NavigationPropertyPanelViewModel viewModel)
{
_viewModel = viewModel;
return;
}
var bootstrapper = AppBootstrapper.Instance;
if (bootstrapper == null)
{
return;
}
_viewModel = bootstrapper.Container.Resolve<NavigationPropertyPanelViewModel>();
DataContext = _viewModel;
}
/// <summary>
/// 在导航相机图像上挂载只读标记叠加层,与主视口共享同一份 Marks 集合(单例 VM),
/// 通过坐标映射服务把探测器小视野坐标换算到导航大视野位置,实现双视野标记同步显示。
/// </summary>
private void AttachMarkOverlay()
{
if (_navMarkOverlay != null)
return;
var doc = _viewModel?.InspectionDocumentation;
if (doc == null)
{
_diag.Warning("[NavMarkDiag] AttachMarkOverlay 中止: InspectionDocumentation 为 null (viewModel 存在={HasVm})", _viewModel != null);
return;
}
try { _mapper = AppBootstrapper.Instance?.Container.Resolve<IViewCoordinateMapper>(); }
catch (Exception ex) { _mapper = null; _diag.Warning(ex, "[NavMarkDiag] IViewCoordinateMapper 解析异常"); }
if (_mapper == null)
{
_diag.Warning("[NavMarkDiag] AttachMarkOverlay 中止: IViewCoordinateMapper 为 null");
return;
}
var overlay = new InspectionMarkOverlay
{
IsReadOnly = true,
IsHitTestVisible = false,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Background = Brushes.Transparent,
ClipToBounds = false,
ShowMarks = doc.ShowMarks
};
Panel.SetZIndex(overlay, 30000);
// 大视野坐标解析:探测器像素 → 导航图归一化 → 导航图像素坐标 → 外层显示坐标。
// overlay 挂在 Viewbox 外层,图标保持屏幕尺寸,不会随导航原图一起缩小。
overlay.CoordinateResolver = mark =>
{
var (w, h) = GetNavImagePixelSize();
if (w <= 0 || h <= 0)
{
_diag.Warning("[NavMarkDiag] Resolver 返回null: 导航图尺寸无效 (w={W}, h={H})", w, h);
return null;
}
var map = doc.CurrentMap;
var context = new ViewMappingContext
{
DetectorImageWidth = map?.ImageWidth ?? 0,
DetectorImageHeight = map?.ImageHeight ?? 0,
NavigationImageWidth = w,
NavigationImageHeight = h,
Motion = mark.StateSnapshot?.Motion ?? MotionState.Default
};
var normalized = _mapper.DetectorPixelToNavigationNormalized(new Point(mark.PixelX, mark.PixelY), context);
if (normalized == null)
{
_diag.Warning("[NavMarkDiag] Resolver 返回null: mapper无法映射 (DetectorImageWidth={DW}, CurrentMap存在={HasMap}, pixel=({PX},{PY}))", context.DetectorImageWidth, map != null, mark.PixelX, mark.PixelY);
return null;
}
var navPixel = new Point(normalized.Value.X * w, normalized.Value.Y * h);
var displayPos = MapNavigationPixelToDisplay(navPixel, w, h);
if (displayPos == null)
{
_diag.Warning("[NavMarkDiag] Resolver 返回null: 显示区域尺寸无效 (hostW={HostW:F0}, hostH={HostH:F0}, navW={W:F0}, navH={H:F0})",
cameraDisplayHost.ActualWidth, cameraDisplayHost.ActualHeight, w, h);
return null;
}
_diag.Information("[NavMarkDiag] Resolver ok: pixel=({PX},{PY}) -> nav=({NX:F0},{NY:F0}) -> display=({X:F0},{Y:F0}) [navW={W:F0},navH={H:F0}]",
mark.PixelX, mark.PixelY, navPixel.X, navPixel.Y, displayPos.Value.X, displayPos.Value.Y, w, h);
return displayPos;
};
overlay.Marks = doc.Marks;
// 显示/隐藏标记随主控 VM 同步
doc.PropertyChanged += (_, ev) =>
{
if (_navMarkOverlay == null)
return;
if (ev.PropertyName == nameof(doc.ShowMarks))
_navMarkOverlay.ShowMarks = doc.ShowMarks;
};
cameraDisplayHost.Children.Add(overlay);
_navMarkOverlay = overlay;
cameraDisplayHost.SizeChanged += (_, _) => SyncMarkOverlaySize();
_diag.Information("[NavMarkDiag] overlay 已挂载, 当前 Marks.Count={Count}, CurrentMap存在={HasMap}, ShowMarks={Show}", doc.Marks.Count, doc.CurrentMap != null, doc.ShowMarks);
// 诊断:监听 Marks 集合变化,确认主视口添加的标记是否同步到导航区(共享单例集合)
doc.Marks.CollectionChanged += (_, ev) =>
_diag.Information("[NavMarkDiag] 导航区收到 Marks 变化: Action={Action}, Count={Count}", ev.Action, doc.Marks.Count);
// 外层显示区域尺寸变化时,需要按新的 Uniform 缩放比例重绘标记。
SyncMarkOverlaySize();
}
private Point? MapNavigationPixelToDisplay(Point navPixel, double navWidth, double navHeight)
{
if (navWidth <= 0 || navHeight <= 0)
return null;
double hostWidth = cameraDisplayHost.ActualWidth;
double hostHeight = cameraDisplayHost.ActualHeight;
if (hostWidth <= 0 || hostHeight <= 0)
return null;
double scale = Math.Min(hostWidth / navWidth, hostHeight / navHeight);
if (scale <= 0 || double.IsNaN(scale) || double.IsInfinity(scale))
return null;
double displayedWidth = navWidth * scale;
double displayedHeight = navHeight * scale;
double offsetX = (hostWidth - displayedWidth) / 2.0;
double offsetY = (hostHeight - displayedHeight) / 2.0;
return new Point(offsetX + navPixel.X * scale, offsetY + navPixel.Y * scale);
}
/// <summary>
/// 导航相机图像的像素尺寸;标记叠加层与红十字线共用此坐标系。
/// 优先取 ViewModel.ImageWidth/Height(拍快照时随图像一并设置),
/// 回退到 CameraImageSource 的像素尺寸。
/// </summary>
private (double Width, double Height) GetNavImagePixelSize()
{
double w = _viewModel?.ImageWidth ?? 0;
double h = _viewModel?.ImageHeight ?? 0;
if (w <= 0 || h <= 0)
{
var src = _viewModel?.CameraImageSource;
if (src != null)
{
w = src.PixelWidth;
h = src.PixelHeight;
}
}
return (w, h);
}
/// <summary>
/// 按当前外层显示区域尺寸重绘标记。
/// 标记位置先在导航图像像素坐标中计算,再映射到 Viewbox 外层的显示坐标;
/// 图标自身保持固定屏幕尺寸,避免被 Viewbox 缩成不可见的小点。
/// </summary>
private void SyncMarkOverlaySize()
{
if (_navMarkOverlay == null)
return;
// 标记坐标由 CoordinateResolver 按当前图像尺寸和显示区域尺寸实时计算,这里仅触发重绘。
_navMarkOverlay.RefreshMarks();
}
/// <summary>
/// 监听 ViewModel 属性变化,当红十字线相关属性更新时刷新绘制。
/// </summary>
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(NavigationPropertyPanelViewModel.CrosshairPixelX):
case nameof(NavigationPropertyPanelViewModel.CrosshairPixelY):
case nameof(NavigationPropertyPanelViewModel.IsCrosshairVisible):
case nameof(NavigationPropertyPanelViewModel.CameraImageSource):
UpdateCrosshair();
SyncMarkOverlaySize();
break;
}
}
/// <summary>
/// 更新红色十字线的位置和可见性。
/// 十字线绘制在 Canvas 上,Canvas 与 Image 重叠在同一 Grid 中,
/// 由 Viewbox 统一缩放。因此十字线坐标直接使用图像像素坐标即可。
/// /// 十字线含义:
/// - 中心点 = 探测器中心在导航图上的当前投影位置
/// - 载物台移动 → 编码器变化 → WorldToPixel 反算 → 十字线移动
/// </summary>
private void UpdateCrosshair()
{
if (_viewModel == null) return;
bool visible = _viewModel.IsCrosshairVisible && _viewModel.HasSnapshot;
crosshairCanvas.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
if (!visible) return;
// 红十字线位置(图像像素坐标系)
double cx = _viewModel.CrosshairPixelX;
double cy = _viewModel.CrosshairPixelY;
// 获取图像尺寸用于设置 Canvas 大小和线条端点
double imgW = _viewModel.ImageWidth;
double imgH = _viewModel.ImageHeight;
if (imgW <= 0 || imgH <= 0) return;
// 设置 Canvas 尺寸与图像一致(Viewbox 内部按像素坐标工作)
crosshairCanvas.Width = imgW;
crosshairCanvas.Height = imgH;
// 垂直线:从图像顶部到底部,X 固定在 cx
// 垂直线
crosshairVertical.X1 = cx;
crosshairVertical.Y1 = 0;
crosshairVertical.X2 = cx;
crosshairVertical.Y2 = imgH;
// 水平线
crosshairHorizontal.X1 = 0;
crosshairHorizontal.Y1 = cy;
crosshairHorizontal.X2 = imgW;
crosshairHorizontal.Y2 = cy;
}
/// <summary>
/// 导航下拉按钮点击:在按钮正下方打开 ContextMenu
/// </summary>
private void NavDropDownButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button button && button.ContextMenu != null)
{
button.ContextMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
button.ContextMenu.PlacementTarget = button;
button.ContextMenu.IsOpen = true;
}
}
/// <summary>
/// 单击导航图像:将点击像素对位到探测器中心。
/// - 跟随模式:使用点击位置
/// - 放大锁定模式:使用框内精确目标位置
/// </summary>
private void ImgCamera_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (_viewModel == null) return;
if (_viewModel.CameraImageSource == null) return;
if (!_viewModel.IsCalibrated || !_viewModel.HasSnapshot) return;
if (_viewModel.IsMoving) return;
double px, py;
if (_isZoomedLocked)
{
// 放大锁定模式:使用精确目标位置
px = _preciseTargetPx.X;
py = _preciseTargetPx.Y;
}
else
{
// 跟随模式:使用点击位置换算
var image = (Image)sender;
var pos = e.GetPosition(image);
px = pos.X / image.ActualWidth * _viewModel.CameraImageSource.PixelWidth;
py = pos.Y / image.ActualHeight * _viewModel.CameraImageSource.PixelHeight;
}
// 边界检查
if (px < 0 || px >= _viewModel.CameraImageSource.PixelWidth ||
py < 0 || py >= _viewModel.CameraImageSource.PixelHeight)
{
return;
}
_ = _viewModel.NavigateToPixel(px, py);
}
#region PiP (Picture-in-Picture) -
/// <summary>
/// 鼠标进入图像区域:显示青框。
/// </summary>
private void ImageContainer_MouseEnter(object sender, MouseEventArgs e)
{
if (_viewModel == null || !_viewModel.HasSnapshot) return;
_pipVisible = true;
cyanCrosshairCanvas.Visibility = Visibility.Visible;
pipCanvas.Visibility = Visibility.Visible;
}
/// <summary>
/// 鼠标离开图像区域:隐藏青框,退出锁定模式。
/// </summary>
private void ImageContainer_MouseLeave(object sender, MouseEventArgs e)
{
_pipVisible = false;
_isZoomedLocked = false;
_roiScreenPx = PipScreenBase;
cyanCrosshairCanvas.Visibility = Visibility.Collapsed;
pipCanvas.Visibility = Visibility.Collapsed;
}
/// <summary>
/// 鼠标移动:
/// - 未放大锁定:框跟随鼠标(粗定位模式)
/// - 放大锁定:鼠标在框内移动映射到精确图像像素位置(精确定位模式)
/// 鼠标移出框区域时自动退出锁定,恢复跟随模式
/// </summary>
private void ImageContainer_MouseMove(object sender, MouseEventArgs e)
{
if (_viewModel == null || !_viewModel.HasSnapshot) return;
if (_viewModel.CameraImageSource == null) return;
var pos = e.GetPosition(imageContainer);
double imgW = _viewModel.ImageWidth;
double imgH = _viewModel.ImageHeight;
if (imgW <= 0 || imgH <= 0) return;
if (!_pipVisible) return;
if (_isZoomedLocked)
{
// 检查鼠标是否还在框区域内
double halfScreen = _roiScreenPx / 2.0;
double frameLeft = _lockedFrameCenter.X - halfScreen;
double frameTop = _lockedFrameCenter.Y - halfScreen;
double frameRight = _lockedFrameCenter.X + halfScreen;
double frameBottom = _lockedFrameCenter.Y + halfScreen;
if (pos.X < frameLeft || pos.X > frameRight ||
pos.Y < frameTop || pos.Y > frameBottom)
{
// 鼠标移出框区域 → 退出锁定,恢复初始状态
_isZoomedLocked = false;
_roiScreenPx = PipScreenBase;
}
}
if (_isZoomedLocked)
{
// ═══ 放大锁定模式 ═══
// 鼠标相对于框中心的偏移 → 映射为图像像素偏移
double magnification = _roiScreenPx / _roiSizePx;
double mouseOffsetX = pos.X - _lockedFrameCenter.X;
double mouseOffsetY = pos.Y - _lockedFrameCenter.Y;
// 鼠标在框内偏移 / 放大倍率 = 图像像素偏移
double imgOffsetX = mouseOffsetX / magnification;
double imgOffsetY = mouseOffsetY / magnification;
// 精确目标位置 = ROI中心 + 偏移(clamp 到裁切区域内)
double halfRoi = _roiSizePx / 2.0;
double targetX = _roiCenterPx.X + imgOffsetX;
double targetY = _roiCenterPx.Y + imgOffsetY;
targetX = Math.Max(_roiCenterPx.X - halfRoi, Math.Min(targetX, _roiCenterPx.X + halfRoi));
targetY = Math.Max(_roiCenterPx.Y - halfRoi, Math.Min(targetY, _roiCenterPx.Y + halfRoi));
targetX = Math.Max(0, Math.Min(targetX, imgW));
targetY = Math.Max(0, Math.Min(targetY, imgH));
_preciseTargetPx = new Point(targetX, targetY);
// 更新青色十字线显示在底图上对应的精确位置
UpdateCyanCrosshair(targetX, targetY, imgW, imgH);
// 画中画位置不变,只更新框内的十字指示(通过青色十字线体现)
UpdatePip(_roiCenterPx.X, _roiCenterPx.Y, imgW, imgH);
}
else
{
// ═══ 跟随模式(粗定位)═══
double cx = Math.Max(0, Math.Min(pos.X, imgW));
double cy = Math.Max(0, Math.Min(pos.Y, imgH));
_roiCenterPx = new Point(cx, cy);
_preciseTargetPx = new Point(cx, cy);
UpdateCyanCrosshair(cx, cy, imgW, imgH);
UpdatePip(cx, cy, imgW, imgH);
}
}
/// <summary>
/// 滚轮:缩放青色框大小。
/// - 第一次放大时进入锁定模式(框中心固定)
/// - 缩回最小时退出锁定模式(恢复跟随)
/// </summary>
private void ImageContainer_MouseWheel(object sender, MouseWheelEventArgs e)
{
if (_viewModel == null || !_viewModel.HasSnapshot) return;
if (!_pipVisible) return;
double oldSize = _roiScreenPx;
if (e.Delta > 0)
{
// 滚轮向上 → 框变大 → 放大显示
_roiScreenPx = Math.Min(_roiScreenPx * PipScaleFactor, PipScreenMax);
// 首次放大时进入锁定模式
if (!_isZoomedLocked && _roiScreenPx > PipScreenBase)
{
_isZoomedLocked = true;
_lockedFrameCenter = _roiCenterPx; // 锁定框中心为当前 ROI 中心的屏幕位置
// lockedFrameCenter 在 Viewbox 内就是图像像素坐标
}
}
else
{
// 滚轮向下 → 框变小 → 缩小显示
_roiScreenPx = Math.Max(_roiScreenPx / PipScaleFactor, PipScreenMin);
// 缩回基础大小时退出锁定模式
if (_roiScreenPx <= PipScreenBase)
{
_roiScreenPx = PipScreenBase;
_isZoomedLocked = false;
}
}
// 刷新画中画
double imgW = _viewModel.ImageWidth;
double imgH = _viewModel.ImageHeight;
if (imgW > 0 && imgH > 0)
{
UpdatePip(_roiCenterPx.X, _roiCenterPx.Y, imgW, imgH);
}
e.Handled = true;
}
/// <summary>
/// 更新青色十字线位置(全图横竖线,标示当前精确关注点)。
/// </summary>
private void UpdateCyanCrosshair(double cx, double cy, double imgW, double imgH)
{
cyanCrosshairCanvas.Width = imgW;
cyanCrosshairCanvas.Height = imgH;
cyanCrosshairVertical.X1 = cx;
cyanCrosshairVertical.Y1 = 0;
cyanCrosshairVertical.X2 = cx;
cyanCrosshairVertical.Y2 = imgH;
cyanCrosshairHorizontal.X1 = 0;
cyanCrosshairHorizontal.Y1 = cy;
cyanCrosshairHorizontal.X2 = imgW;
cyanCrosshairHorizontal.Y2 = cy;
}
/// <summary>
/// 更新画中画内容和位置。
///
/// 逻辑:
/// 1. 以 roiCenterPx 为中心,裁切 roiSizePx × roiSizePx 的原图区域
/// 2. 将裁切结果显示在大小为 roiScreenPx × roiScreenPx 的青色框内
/// 3. 跟随模式:框中心在鼠标位置;锁定模式:框中心固定在 lockedFrameCenter
/// 4. 如果探测器中心(红十字)在裁切区域内,在框内绘制红色十字线
/// </summary>
private void UpdatePip(double cx, double cy, double imgW, double imgH)
{
var source = _viewModel?.CameraImageSource;
if (source == null) return;
int srcW = source.PixelWidth;
int srcH = source.PixelHeight;
double halfRoi = _roiSizePx / 2.0;
// 计算裁切区域(图像像素),与边界求交
int left = (int)Math.Max(0, cx - halfRoi);
int top = (int)Math.Max(0, cy - halfRoi);
int right = (int)Math.Min(srcW, cx + halfRoi);
int bottom = (int)Math.Min(srcH, cy + halfRoi);
int cropW = right - left;
int cropH = bottom - top;
if (cropW <= 0 || cropH <= 0) return;
// 裁切原图
try
{
var rect = new Int32Rect(left, top, cropW, cropH);
var cropped = new CroppedBitmap(source, rect);
pipImage.Source = cropped;
}
catch
{
return;
}
// 设置框的显示大小
pipBorder.Width = _roiScreenPx;
pipBorder.Height = _roiScreenPx;
// 定位框中心
double frameCenterX = _isZoomedLocked ? _lockedFrameCenter.X : cx;
double frameCenterY = _isZoomedLocked ? _lockedFrameCenter.Y : cy;
double pipLeft = frameCenterX - _roiScreenPx / 2.0;
double pipTop = frameCenterY - _roiScreenPx / 2.0;
Canvas.SetLeft(pipBorder, pipLeft);
Canvas.SetTop(pipBorder, pipTop);
// 确保 pipCanvas 尺寸覆盖整个图像
pipCanvas.Width = imgW;
pipCanvas.Height = imgH;
}
#endregion PiP
}
}