5 Commits

Author SHA1 Message Date
zhengxuan.zhang 318d1813b8 录屏功能 2026-05-07 15:12:06 +08:00
zhengxuan.zhang 8500f8b5ed 修复CNC模式下,+号订阅事件;修复CNC和普通模式的切换问题 2026-05-07 13:31:14 +08:00
zhengxuan.zhang 3c9b3a2731 算子工具箱增加 +形式 2026-05-07 13:06:13 +08:00
zhengxuan.zhang aa39f8ca95 修复流程图连线对齐 2026-05-07 11:45:17 +08:00
zhengxuan.zhang 964284d4b1 调整rabbion布局,移动下拉算子列表;将扫描相关移动到Tab页;调整 算子参数控件样式 2026-05-07 11:34:51 +08:00
21 changed files with 1313 additions and 115 deletions
@@ -1,4 +1,6 @@
using System;
using Moq;
using Prism.Events;
using XplorePlane.Tests.Helpers;
using XplorePlane.ViewModels;
using Xunit;
@@ -13,7 +15,8 @@ namespace XplorePlane.Tests.Pipeline
private OperatorToolboxViewModel CreateVm(string[]? keys = null)
{
var mock = TestHelpers.CreateMockImageService(keys);
return new OperatorToolboxViewModel(mock.Object);
var mockEventAggregator = new Mock<IEventAggregator>();
return new OperatorToolboxViewModel(mock.Object, mockEventAggregator.Object);
}
[Fact]
@@ -0,0 +1,180 @@
using System;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using Emgu.CV;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using XplorePlane.Services.Recording;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// FsCheck property-based tests for ViewportRecordingService static helpers.
/// </summary>
public class ViewportRecordingPropertyTests
{
// ── Property 1: Timer formatting correctness ─────────────────────────
// Feature: viewport-recording, Property 1: Timer formatting correctness
/// <summary>
/// **Validates: Requirements 2.2**
/// For any elapsed time value in seconds between 0 and 3599 (inclusive),
/// FormatElapsedTime SHALL produce a string in the format "MM:SS" where
/// MM is zero-padded minutes (0059) and SS is zero-padded seconds (0059),
/// and the numeric value represented equals the input seconds.
/// </summary>
[Property(MaxTest = 100)]
public Property FormatElapsedTime_ProducesCorrectMMSS_ForAllValidSeconds()
{
var secondsGen = Gen.Choose(0, 3599);
return Prop.ForAll(
secondsGen.ToArbitrary(),
totalSeconds =>
{
string result = ViewportRecordingService.FormatElapsedTime(totalSeconds);
// Verify format is exactly "MM:SS" (5 chars, colon at index 2)
bool correctLength = result.Length == 5;
bool hasColon = result[2] == ':';
bool matchesPattern = Regex.IsMatch(result, @"^\d{2}:\d{2}$");
// Parse back and verify numeric correctness
int parsedMinutes = int.Parse(result.Substring(0, 2));
int parsedSeconds = int.Parse(result.Substring(3, 2));
bool minutesInRange = parsedMinutes >= 0 && parsedMinutes <= 59;
bool secondsInRange = parsedSeconds >= 0 && parsedSeconds <= 59;
// Verify the numeric value equals the input
int reconstructed = parsedMinutes * 60 + parsedSeconds;
bool valueCorrect = reconstructed == totalSeconds;
return correctLength && hasColon && matchesPattern
&& minutesInRange && secondsInRange && valueCorrect;
});
}
// ── Property 2: Recording filename generation ────────────────────────
// Feature: viewport-recording, Property 2: Recording filename generation
/// <summary>
/// **Validates: Requirements 3.2**
/// For any valid DateTime value, GenerateFileName SHALL produce a string
/// matching the pattern REC_yyyyMMdd_HHmmss.mp4, and parsing the timestamp
/// portion back to a DateTime SHALL yield the same year, month, day, hour,
/// minute, and second as the input.
/// </summary>
[Property(MaxTest = 100)]
public Property GenerateFileName_MatchesPattern_AndTimestampIsReversible()
{
var dateTimeGen = ArbMap.Default.GeneratorFor<DateTime>();
return Prop.ForAll(
dateTimeGen.ToArbitrary(),
(DateTime dateTime) =>
{
var fileName = ViewportRecordingService.GenerateFileName(dateTime);
// Verify pattern: REC_yyyyMMdd_HHmmss.mp4
var pattern = @"^REC_\d{8}_\d{6}\.mp4$";
bool matchesPattern = Regex.IsMatch(fileName, pattern);
// Extract timestamp portion and parse back
var timestampStr = fileName.Substring(4, 15); // "yyyyMMdd_HHmmss"
bool canParse = DateTime.TryParseExact(
timestampStr,
"yyyyMMdd_HHmmss",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out DateTime parsed);
// Verify reversibility: year, month, day, hour, minute, second match
bool isReversible = canParse
&& parsed.Year == dateTime.Year
&& parsed.Month == dateTime.Month
&& parsed.Day == dateTime.Day
&& parsed.Hour == dateTime.Hour
&& parsed.Minute == dateTime.Minute
&& parsed.Second == dateTime.Second;
return matchesPattern && isReversible;
});
}
// ── Property 3: VIDEO folder path construction ───────────────────────
// Feature: viewport-recording, Property 3: VIDEO folder path construction
/// <summary>
/// **Validates: Requirements 4.2**
/// For any non-empty string rootPath, GetVideoFolderPath SHALL produce a path
/// equal to Path.Combine(rootPath, "VIDEO"), ensuring the VIDEO subfolder
/// is always constructed relative to the given root path.
/// </summary>
[Property(MaxTest = 100)]
public Property GetVideoFolderPath_AlwaysReturnsVideoSubfolder()
{
// Generate non-empty strings (filter out empty/whitespace)
var nonEmptyStringGen = ArbMap.Default.GeneratorFor<NonEmptyString>();
return Prop.ForAll(
nonEmptyStringGen.ToArbitrary(),
(NonEmptyString rootPath) =>
{
string result = ViewportRecordingService.GetVideoFolderPath(rootPath.Get);
// Verify the result equals Path.Combine(rootPath, "VIDEO")
string expected = System.IO.Path.Combine(rootPath.Get, "VIDEO");
bool equalsExpected = result == expected;
// Verify the result ends with "VIDEO" segment
bool endsWithVideo = result.EndsWith("VIDEO") || result.EndsWith("VIDEO/") || result.EndsWith("VIDEO\\");
return equalsExpected && endsWithVideo;
});
}
// ── Property 4: Null-source frame produces black frame ───────────────
// Feature: viewport-recording, Property 4: Null-source frame produces black frame
/// <summary>
/// **Validates: Requirements 6.3**
/// For any positive integer dimensions (width, height), CreateBlackFrame SHALL
/// produce a Mat with the correct dimensions, 3 channels (BGR24), and all pixel
/// values equal to zero (pure black).
/// </summary>
[Property(MaxTest = 50)]
public Property CreateBlackFrame_ProducesCorrectBlackFrame_ForAnyPositiveDimensions()
{
// Generate positive integers for width and height (1..512 to keep tests fast)
var widthGen = Gen.Choose(1, 512);
var heightGen = Gen.Choose(1, 512);
var dimensionsGen = widthGen.SelectMany(w => heightGen.Select(h => (w, h)));
return Prop.ForAll(
dimensionsGen.ToArbitrary(),
((int width, int height) dims) =>
{
using var frame = ViewportRecordingService.CreateBlackFrame(dims.width, dims.height);
// Verify dimensions
bool correctWidth = frame.Width == dims.width;
bool correctHeight = frame.Height == dims.height;
// Verify 3 channels (BGR24)
bool correctChannels = frame.NumberOfChannels == 3;
// Verify all pixels are zero (black)
// Get raw byte data and check all zeros
int totalBytes = dims.width * dims.height * 3;
byte[] pixelData = new byte[totalBytes];
System.Runtime.InteropServices.Marshal.Copy(frame.DataPointer, pixelData, 0, totalBytes);
bool allZero = System.Array.TrueForAll(pixelData, b => b == 0);
return correctWidth && correctHeight && correctChannels && allZero;
});
}
}
}
@@ -10,6 +10,8 @@
<ItemGroup>
<Compile Remove="Helpers\Define.cs" />
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
</ItemGroup>
<ItemGroup>
+20
View File
@@ -40,6 +40,7 @@ using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Matrix;
using XplorePlane.Services.Measurement;
using XplorePlane.Services.Recipe;
using XplorePlane.Services.Recording;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels;
using XplorePlane.ViewModels.Cnc;
@@ -217,6 +218,22 @@ namespace XplorePlane
Log.Error(ex, "数据库资源释放失败,忽略该错误继续退出 | Database resource release failed, ignoring error and continuing exit");
}
// 释放录制服务资源
try
{
var bootstrapper = AppBootstrapper.Instance;
if (bootstrapper != null)
{
var recordingService = bootstrapper.Container.Resolve<IViewportRecordingService>();
recordingService?.Dispose();
Log.Information("录制服务资源已释放");
}
}
catch (Exception ex)
{
Log.Error(ex, "录制服务资源释放失败");
}
Log.CloseAndFlush();
base.OnExit(e);
}
@@ -427,6 +444,9 @@ namespace XplorePlane
new CameraFactory().CreateController("Basler"));
containerRegistry.RegisterSingleton<ICameraService, CameraService>();
// ── 录制服务(单例)──
containerRegistry.RegisterSingleton<IViewportRecordingService, ViewportRecordingService>();
Log.Information("依赖注入容器配置完成");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

@@ -0,0 +1,11 @@
using Prism.Events;
namespace XplorePlane.Events
{
/// <summary>
/// 请求向当前流水线追加算子的事件,由算子工具箱发布,PipelineEditorViewModel 订阅
/// </summary>
public sealed class AddOperatorRequestedEvent : PubSubEvent<string>
{
}
}
@@ -0,0 +1,14 @@
using Prism.Events;
namespace XplorePlane.Events
{
/// <summary>
/// 请求 ViewportPanelView 提供 captureTarget 用于录制
/// </summary>
public class RequestCaptureTargetEvent : PubSubEvent { }
/// <summary>
/// ViewportPanelView 响应录制请求,提供 captureTarget
/// </summary>
public class ProvideCaptureTargetEvent : PubSubEvent<System.Windows.FrameworkElement> { }
}
@@ -0,0 +1,53 @@
#nullable enable
using System;
using System.Windows;
namespace XplorePlane.Services.Recording
{
public enum RecordingState
{
Idle,
Recording,
Saving
}
public class RecordingStateChangedEventArgs : EventArgs
{
public RecordingState OldState { get; }
public RecordingState NewState { get; }
public string? ErrorMessage { get; }
public string? SavedFilePath { get; }
public RecordingStateChangedEventArgs(
RecordingState oldState,
RecordingState newState,
string? errorMessage = null,
string? savedFilePath = null)
{
OldState = oldState;
NewState = newState;
ErrorMessage = errorMessage;
SavedFilePath = savedFilePath;
}
}
public interface IViewportRecordingService : IDisposable
{
RecordingState CurrentState { get; }
TimeSpan ElapsedTime { get; }
event EventHandler<RecordingStateChangedEventArgs> StateChanged;
/// <summary>
/// 开始录制。需要传入用于 RenderTargetBitmap 捕获的 Visual 引用。
/// 返回 true 表示录制成功启动,false 表示前置条件不满足。
/// </summary>
bool StartRecording(FrameworkElement captureTarget);
/// <summary>
/// 停止录制并开始保存。
/// </summary>
void StopRecording();
}
}
@@ -0,0 +1,538 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services.Storage;
namespace XplorePlane.Services.Recording
{
public sealed class ViewportRecordingService : IViewportRecordingService
{
// ── Dependencies ─────────────────────────────────────────────────────
private readonly IXpDataPathService _xpDataPathService;
private readonly ILoggerService _logger;
// ── State ────────────────────────────────────────────────────────────
private RecordingState _currentState = RecordingState.Idle;
private DateTime _recordingStartTime;
private DispatcherTimer? _captureTimer;
private FrameworkElement? _captureTarget;
private int _lockedWidth;
private int _lockedHeight;
private string? _currentFilePath;
// ── Encoding pipeline (producer-consumer) ────────────────────────────
private BlockingCollection<Mat>? _frameQueue;
private Task? _encoderTask;
private VideoWriter? _videoWriter;
private CancellationTokenSource? _encoderCts;
// ── Constants ────────────────────────────────────────────────────────
private const int TargetFps = 15;
private const int CaptureIntervalMs = 66; // ~15fps
private const int MinDiskSpaceMB = 100;
private const int MaxRecordingMinutes = 60;
private const int MaxFrameQueueSize = 60; // ~4 seconds buffer
// ── Constructor ──────────────────────────────────────────────────────
public ViewportRecordingService(
IXpDataPathService xpDataPathService,
ILoggerService logger)
{
_xpDataPathService = xpDataPathService ?? throw new ArgumentNullException(nameof(xpDataPathService));
_logger = logger?.ForModule<ViewportRecordingService>() ?? throw new ArgumentNullException(nameof(logger));
}
// ── IViewportRecordingService Implementation ─────────────────────────
public RecordingState CurrentState => _currentState;
public TimeSpan ElapsedTime =>
_currentState == RecordingState.Recording
? DateTime.Now - _recordingStartTime
: TimeSpan.Zero;
public event EventHandler<RecordingStateChangedEventArgs>? StateChanged;
public bool StartRecording(FrameworkElement captureTarget)
{
if (_currentState != RecordingState.Idle)
{
_logger.Warn("StartRecording called while state is {0}", _currentState);
return false;
}
string videoFolder = GetVideoFolderPath(_xpDataPathService.RootPath);
if (!ValidatePreConditions(captureTarget, videoFolder))
{
return false;
}
// Lock the current resolution of the viewport
_lockedWidth = (int)captureTarget.ActualWidth;
_lockedHeight = (int)captureTarget.ActualHeight;
// Generate file path
_recordingStartTime = DateTime.Now;
_currentFilePath = Path.Combine(videoFolder, GenerateFileName(_recordingStartTime));
_captureTarget = captureTarget;
// Initialize encoding pipeline
if (!InitializeEncodingPipeline())
{
return false;
}
// Start frame capture timer on UI thread
StartCaptureTimer();
// Transition state
var oldState = _currentState;
_currentState = RecordingState.Recording;
RaiseStateChanged(oldState, _currentState);
_logger.Info("录制已开始: {0} ({1}x{2} @{3}fps)",
_currentFilePath, _lockedWidth, _lockedHeight, TargetFps);
return true;
}
public void StopRecording()
{
// Full implementation in task 2.3
if (_currentState != RecordingState.Recording)
{
return;
}
var oldState = _currentState;
_currentState = RecordingState.Saving;
RaiseStateChanged(oldState, _currentState);
// Stop capture timer
_captureTimer?.Stop();
_captureTimer = null;
// Signal encoder to finish
_frameQueue?.CompleteAdding();
// Wait for encoder to drain remaining frames
try
{
_encoderTask?.Wait(TimeSpan.FromSeconds(30));
}
catch (AggregateException ex)
{
_logger.Error(ex, "编码线程异常");
}
// Release resources
CleanupEncodingResources();
var savedPath = _currentFilePath;
_currentState = RecordingState.Idle;
RaiseStateChanged(RecordingState.Saving, RecordingState.Idle, savedFilePath: savedPath);
_logger.Info("录制已保存: {0}", savedPath);
}
public void Dispose()
{
if (_currentState == RecordingState.Recording)
{
StopRecording();
}
else if (_currentState == RecordingState.Saving)
{
// Wait for encoder to finish draining, then clean up
try
{
_frameQueue?.CompleteAdding();
_encoderTask?.Wait(TimeSpan.FromSeconds(5));
}
catch { /* best effort */ }
CleanupEncodingResources();
_currentState = RecordingState.Idle;
}
CleanupEncodingResources();
}
// ── Static Helpers (internal for testing) ────────────────────────────
/// <summary>
/// 构造 VIDEO 文件夹完整路径
/// </summary>
internal static string GetVideoFolderPath(string rootPath)
{
return Path.Combine(rootPath, "VIDEO");
}
/// <summary>
/// 将总秒数格式化为 "MM:SS" 计时器显示文本
/// </summary>
internal static string FormatElapsedTime(int totalSeconds)
{
int minutes = totalSeconds / 60;
int seconds = totalSeconds % 60;
return $"{minutes:D2}:{seconds:D2}";
}
/// <summary>
/// 根据录制开始时间生成文件名,格式为 REC_yyyyMMdd_HHmmss.mp4
/// </summary>
internal static string GenerateFileName(DateTime startTime)
{
return $"REC_{startTime:yyyyMMdd_HHmmss}.mp4";
}
/// <summary>
/// 创建指定分辨率的纯黑帧 (BGR24, 所有像素值为 0)
/// </summary>
internal static Mat CreateBlackFrame(int width, int height)
{
// Create a BGR24 Mat initialized to zeros (black)
var mat = new Mat(height, width, DepthType.Cv8U, 3);
mat.SetTo(new MCvScalar(0, 0, 0));
return mat;
}
// ── Private Methods ──────────────────────────────────────────────────
private bool InitializeEncodingPipeline()
{
try
{
_frameQueue = new BlockingCollection<Mat>(MaxFrameQueueSize);
_encoderCts = new CancellationTokenSource();
// Initialize VideoWriter with H.264 codec
int fourcc = VideoWriter.Fourcc('a', 'v', 'c', '1');
_videoWriter = new VideoWriter(
_currentFilePath!,
fourcc,
TargetFps,
new System.Drawing.Size(_lockedWidth, _lockedHeight),
true); // isColor = true for BGR
if (!_videoWriter.IsOpened)
{
_logger.Error(new InvalidOperationException("VideoWriter failed to open"),
"无法初始化视频编码器: {0}", _currentFilePath);
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle,
errorMessage: "无法初始化视频编码器");
CleanupEncodingResources();
return false;
}
// Start background encoder thread
var ct = _encoderCts.Token;
_encoderTask = Task.Factory.StartNew(
() => EncoderLoop(ct),
ct,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
return true;
}
catch (Exception ex)
{
_logger.Error(ex, "初始化编码管线失败");
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle,
errorMessage: $"初始化编码管线失败:{ex.Message}");
CleanupEncodingResources();
return false;
}
}
private void StartCaptureTimer()
{
_captureTimer = new DispatcherTimer(DispatcherPriority.Render)
{
Interval = TimeSpan.FromMilliseconds(CaptureIntervalMs)
};
_captureTimer.Tick += OnCaptureTimerTick;
_captureTimer.Start();
}
private void OnCaptureTimerTick(object? sender, EventArgs e)
{
if (_currentState != RecordingState.Recording || _captureTarget == null)
return;
// Check max recording duration
var elapsed = DateTime.Now - _recordingStartTime;
if (elapsed.TotalMinutes >= MaxRecordingMinutes)
{
_logger.Info("已达到最大录制时长 {0} 分钟,自动停止", MaxRecordingMinutes);
StopRecording();
return;
}
// Capture frame from the visual tree
Mat? frame = CaptureFrame();
if (frame == null)
{
// Generate black frame when capture fails (task 2.4 requirement)
frame = CreateBlackFrame(_lockedWidth, _lockedHeight);
}
// Enqueue frame for encoding (producer side)
if (_frameQueue != null && !_frameQueue.IsAddingCompleted)
{
if (!_frameQueue.TryAdd(frame))
{
// Queue is full - drop frame (backpressure)
_logger.Warn("帧队列已满,丢弃当前帧");
frame.Dispose();
}
}
else
{
frame.Dispose();
}
}
private Mat? CaptureFrame()
{
try
{
if (_captureTarget == null)
return null;
// Render the visual to a RenderTargetBitmap
var rtb = new RenderTargetBitmap(
_lockedWidth, _lockedHeight,
96, 96,
PixelFormats.Pbgra32);
rtb.Render(_captureTarget);
// Convert RenderTargetBitmap to BGR24 Mat
return BitmapSourceToMat(rtb);
}
catch (Exception ex)
{
_logger.Warn("帧捕获失败: {0}", ex.Message);
return null;
}
}
/// <summary>
/// Convert a BitmapSource (Pbgra32) to an Emgu.CV Mat (BGR24)
/// </summary>
private Mat BitmapSourceToMat(BitmapSource source)
{
// Ensure we have Bgr24 format for OpenCV compatibility
BitmapSource bgr24Source;
if (source.Format != PixelFormats.Bgr24)
{
bgr24Source = new FormatConvertedBitmap(source, PixelFormats.Bgr24, null, 0);
}
else
{
bgr24Source = source;
}
int width = bgr24Source.PixelWidth;
int height = bgr24Source.PixelHeight;
int stride = width * 3; // BGR24 = 3 bytes per pixel
var mat = new Mat(height, width, DepthType.Cv8U, 3);
bgr24Source.CopyPixels(new Int32Rect(0, 0, width, height), mat.DataPointer, height * stride, stride);
return mat;
}
private void EncoderLoop(CancellationToken ct)
{
try
{
foreach (var frame in _frameQueue!.GetConsumingEnumerable(ct))
{
try
{
if (_videoWriter != null && _videoWriter.IsOpened)
{
_videoWriter.Write(frame);
}
}
catch (Exception ex)
{
_logger.Error(ex, "编码帧写入失败");
HandleEncoderError(ex);
return; // frame disposed by finally
}
finally
{
frame.Dispose();
}
}
}
catch (OperationCanceledException)
{
// Normal cancellation
_logger.Debug("编码线程已取消");
}
catch (Exception ex)
{
_logger.Error(ex, "编码线程异常退出");
}
}
private void HandleEncoderError(Exception ex)
{
_logger.Error(ex, "编码错误,中止录制");
var filePath = _currentFilePath;
// 在 UI 线程上执行状态转换
Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
{
if (_currentState == RecordingState.Recording)
{
_captureTimer?.Stop();
_captureTimer = null;
var oldState = _currentState;
_currentState = RecordingState.Idle;
CleanupEncodingResources();
// 删除未完成文件
if (filePath != null && File.Exists(filePath))
{
try { File.Delete(filePath); } catch { /* best effort */ }
}
RaiseStateChanged(oldState, RecordingState.Idle,
errorMessage: $"编码错误:{ex.Message}");
}
}));
}
private void CleanupEncodingResources()
{
_encoderCts?.Cancel();
try
{
_videoWriter?.Dispose();
}
catch { /* best effort */ }
_videoWriter = null;
try
{
_frameQueue?.Dispose();
}
catch { /* best effort */ }
_frameQueue = null;
_encoderCts?.Dispose();
_encoderCts = null;
_encoderTask = null;
_captureTarget = null;
}
/// <summary>
/// 录制前置条件校验:检查 captureTarget、目录可创建性、磁盘空间及目录可写性。
/// 任何一项失败均通过 StateChanged 事件传递错误信息并返回 false。
/// </summary>
private bool ValidatePreConditions(FrameworkElement captureTarget, string videoFolder)
{
// 1. 检查 captureTarget 不为 null 且尺寸有效
if (captureTarget == null)
{
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, errorMessage: "视口尚未就绪");
return false;
}
int width = (int)captureTarget.ActualWidth;
int height = (int)captureTarget.ActualHeight;
if (width <= 0 || height <= 0)
{
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, errorMessage: "视口尚未就绪");
return false;
}
// 2. 尝试递归创建 videoFolder(如不存在)
try
{
Directory.CreateDirectory(videoFolder);
}
catch (Exception ex)
{
_logger.Error(ex, "无法创建录制目录:{0}", videoFolder);
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle,
errorMessage: $"无法创建录制目录:{ex.Message}");
return false;
}
// 3. 检查磁盘可用空间 >= 100MB
try
{
string root = Path.GetPathRoot(Path.GetFullPath(videoFolder))
?? videoFolder;
var driveInfo = new DriveInfo(root);
const long minFreeBytes = (long)MinDiskSpaceMB * 1024 * 1024;
if (driveInfo.AvailableFreeSpace < minFreeBytes)
{
string msg = $"磁盘可用空间不足(需要至少 {MinDiskSpaceMB}MB";
_logger.Warn(msg);
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle, errorMessage: msg);
return false;
}
}
catch (Exception ex)
{
_logger.Error(ex, "检查磁盘空间失败");
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle,
errorMessage: $"检查磁盘空间失败:{ex.Message}");
return false;
}
// 4. 检查目录可写:创建临时文件后立即删除
string tempFile = Path.Combine(videoFolder, $".write_check_{Guid.NewGuid():N}.tmp");
try
{
using (File.Create(tempFile)) { /* 仅验证可写性 */ }
File.Delete(tempFile);
}
catch (Exception ex)
{
_logger.Error(ex, "录制目录不可写:{0}", videoFolder);
RaiseStateChanged(RecordingState.Idle, RecordingState.Idle,
errorMessage: $"录制目录不可写:{ex.Message}");
// 尽力清理临时文件
try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { /* best effort */ }
return false;
}
return true;
}
private void RaiseStateChanged(
RecordingState oldState,
RecordingState newState,
string? errorMessage = null,
string? savedFilePath = null)
{
StateChanged?.Invoke(this, new RecordingStateChangedEventArgs(
oldState, newState, errorMessage, savedFilePath));
}
}
}
@@ -324,6 +324,9 @@ namespace XplorePlane.ViewModels.Cnc
}
}
/// <summary>供外部直接 await 的保存方法</summary>
public Task SaveAsync() => ExecuteSaveProgramAsync();
private async Task ExecuteSaveProgramAsync()
{
if (_currentProgram == null)
@@ -77,6 +77,9 @@ namespace XplorePlane.ViewModels.Cnc
_editorViewModel.PropertyChanged += OnEditorPropertyChanged;
RefreshFromSelection();
_eventAggregator?.GetEvent<AddOperatorRequestedEvent>()
.Subscribe(key => { if (CanAddOperator(key)) AddOperator(key); });
}
public ObservableCollection<PipelineNodeViewModel> PipelineNodes { get; }
@@ -174,6 +177,9 @@ namespace XplorePlane.ViewModels.Cnc
RaiseCommandCanExecuteChanged();
}
private bool CanAddOperator(string operatorKey) =>
HasActiveModule && !string.IsNullOrWhiteSpace(operatorKey);
private void AddOperator(string operatorKey)
{
if (!HasActiveModule || string.IsNullOrWhiteSpace(operatorKey))
@@ -1,8 +1,11 @@
using Prism.Commands;
using Prism.Events;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using XplorePlane.Events;
using XplorePlane.Services;
namespace XplorePlane.ViewModels
@@ -35,17 +38,20 @@ namespace XplorePlane.ViewModels
public class OperatorToolboxViewModel : BindableBase
{
private readonly IImageProcessingService _imageProcessingService;
private readonly IEventAggregator _eventAggregator;
private string _searchText = string.Empty;
private OperatorGroupViewModel _selectedGroup;
// UI 元数据(分类 + 图标)由 ProcessorUiMetadata 统一提供,保持工具箱与流水线图标一致
public OperatorToolboxViewModel(IImageProcessingService imageProcessingService)
public OperatorToolboxViewModel(IImageProcessingService imageProcessingService, IEventAggregator eventAggregator)
{
_imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
AvailableOperators = new ObservableCollection<OperatorDescriptor>();
FilteredOperators = new ObservableCollection<OperatorDescriptor>();
FilteredGroups = new ObservableCollection<OperatorGroupViewModel>();
AddOperatorCommand = new DelegateCommand<string>(AddOperator);
LoadOperators();
}
@@ -53,6 +59,8 @@ namespace XplorePlane.ViewModels
public ObservableCollection<OperatorDescriptor> FilteredOperators { get; }
public ObservableCollection<OperatorGroupViewModel> FilteredGroups { get; }
public DelegateCommand<string> AddOperatorCommand { get; }
public OperatorGroupViewModel SelectedGroup
{
get => _selectedGroup;
@@ -124,5 +132,11 @@ namespace XplorePlane.ViewModels
"检测分析" => 6,
_ => 99
};
private void AddOperator(string operatorKey)
{
if (!string.IsNullOrWhiteSpace(operatorKey))
_eventAggregator.GetEvent<AddOperatorRequestedEvent>().Publish(operatorKey);
}
}
}
@@ -42,6 +42,7 @@ namespace XplorePlane.ViewModels
private string _pipelineFileDisplayName = DefaultPipelineFileDisplayName;
private string _currentFilePath;
private PipelineNodeViewModel _executionEndNode;
private bool _isModified;
private CancellationTokenSource _executionCts;
private CancellationTokenSource _debounceCts;
@@ -83,6 +84,9 @@ namespace XplorePlane.ViewModels
_eventAggregator.GetEvent<ManualImageLoadedEvent>()
.Subscribe(OnManualImageLoaded);
_eventAggregator.GetEvent<AddOperatorRequestedEvent>()
.Subscribe(key => { if (CanAddOperator(key)) AddOperator(key); });
}
// ── State Properties ──────────────────────────────────────────
@@ -169,6 +173,13 @@ namespace XplorePlane.ViewModels
private set => SetProperty(ref _pipelineFileDisplayName, value);
}
/// <summary>流水线是否有未保存的修改</summary>
public bool IsModified
{
get => _isModified;
private set => SetProperty(ref _isModified, value);
}
public PipelineNodeViewModel ExecutionEndNode
{
get => _executionEndNode;
@@ -263,6 +274,7 @@ namespace XplorePlane.ViewModels
_logger.Info("节点已添加到 PipelineNodes{Key} ({DisplayName}),当前节点数={Count}",
operatorKey, displayName, PipelineNodes.Count);
SetInfoStatus($"已添加算子:{displayName}");
IsModified = true;
TriggerDebouncedExecution();
}
@@ -280,6 +292,7 @@ namespace XplorePlane.ViewModels
SelectNeighborAfterRemoval(removedIndex);
SetInfoStatus($"已移除算子:{node.DisplayName}");
IsModified = true;
TriggerDebouncedExecution();
}
@@ -291,6 +304,7 @@ namespace XplorePlane.ViewModels
PipelineNodes.Move(index, index - 1);
RenumberNodes();
UpdateExecutionRangeState();
IsModified = true;
TriggerDebouncedExecution();
}
@@ -302,6 +316,7 @@ namespace XplorePlane.ViewModels
PipelineNodes.Move(index, index + 1);
RenumberNodes();
UpdateExecutionRangeState();
IsModified = true;
TriggerDebouncedExecution();
}
@@ -322,6 +337,7 @@ namespace XplorePlane.ViewModels
UpdateExecutionRangeState();
SelectedNode = node;
SetInfoStatus($"已调整算子顺序:{node.DisplayName}");
IsModified = true;
TriggerDebouncedExecution();
}
@@ -334,6 +350,7 @@ namespace XplorePlane.ViewModels
SetInfoStatus(node.IsEnabled
? $"已启用算子:{node.DisplayName}"
: $"已停用算子:{node.DisplayName}");
IsModified = true;
TriggerDebouncedExecution();
}
@@ -403,6 +420,7 @@ namespace XplorePlane.ViewModels
{
if (e.PropertyName == nameof(ProcessorParameterVM.Value))
{
IsModified = true;
if (TryReportInvalidParameters())
return;
@@ -620,9 +638,13 @@ namespace XplorePlane.ViewModels
PreviewImage = null;
_currentFilePath = null;
PipelineFileDisplayName = DefaultPipelineFileDisplayName;
IsModified = false;
SetInfoStatus("已新建流水线");
}
/// <summary>供外部直接 await 的保存方法</summary>
public Task SaveAsync() => SavePipelineAsync();
private async Task SavePipelineAsync()
{
if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100)
@@ -671,6 +693,7 @@ namespace XplorePlane.ViewModels
var model = BuildPipelineModel();
await _persistenceService.SaveAsync(model, filePath);
PipelineFileDisplayName = FormatPipelinePath(filePath);
IsModified = false;
SetInfoStatus($"流水线已保存:{Path.GetFileName(filePath)}");
}
catch (IOException ex)
@@ -747,6 +770,7 @@ namespace XplorePlane.ViewModels
UpdateExecutionRangeState();
_logger.Info("流水线已加载:{Name},节点数={Count}", model.Name, PipelineNodes.Count);
IsModified = false;
SetInfoStatus($"已加载流水线:{model.Name}{PipelineNodes.Count} 个节点)");
}
catch (Exception ex)
+191 -3
View File
@@ -11,8 +11,10 @@ using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using XplorePlane.Events;
using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Recording;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.Views;
@@ -36,6 +38,11 @@ namespace XplorePlane.ViewModels
private readonly IXpDataPathService _xpDataPathService;
private readonly CncEditorViewModel _cncEditorViewModel;
private readonly CncPageView _cncPageView;
private readonly PipelineEditorViewModel _pipelineEditorViewModel;
private readonly PipelineEditorView _pipelineEditorView;
private readonly IViewportRecordingService _recordingService;
private DispatcherTimer? _statusMessageTimer;
private DispatcherTimer? _recordingTimerDispatcher;
public string LicenseInfo
{
@@ -50,6 +57,31 @@ namespace XplorePlane.ViewModels
set => SetProperty(ref _statusMessage, value);
}
private bool _isRecording;
public bool IsRecording
{
get => _isRecording;
private set => SetProperty(ref _isRecording, value);
}
private bool _isRecordingSaving;
public bool IsRecordingSaving
{
get => _isRecordingSaving;
private set => SetProperty(ref _isRecordingSaving, value);
}
private string _recordingTimerText = "00:00";
public string RecordingTimerText
{
get => _recordingTimerText;
private set => SetProperty(ref _recordingTimerText, value);
}
public bool IsTimerOverlayVisible => IsRecording;
public bool CanRecord => !IsRecording && !IsRecordingSaving;
public ObservableCollection<object> NavigationTree { get; set; }
// 导航命令
@@ -112,6 +144,9 @@ namespace XplorePlane.ViewModels
// 辅助线命令
public DelegateCommand ToggleCrosshairCommand { get; }
// 录屏命令
public DelegateCommand ToggleRecordingCommand { get; }
// 设置命令
public DelegateCommand OpenLanguageSwitcherCommand { get; }
public DelegateCommand OpenRealTimeLogViewerCommand { get; }
@@ -203,7 +238,8 @@ namespace XplorePlane.ViewModels
IContainerProvider containerProvider,
IEventAggregator eventAggregator,
IMainViewportService mainViewportService,
IXpDataPathService xpDataPathService)
IXpDataPathService xpDataPathService,
IViewportRecordingService recordingService)
{
_logger = logger?.ForModule<MainViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider));
@@ -212,6 +248,8 @@ namespace XplorePlane.ViewModels
_xpDataPathService = xpDataPathService ?? throw new ArgumentNullException(nameof(xpDataPathService));
_cncEditorViewModel = _containerProvider.Resolve<CncEditorViewModel>();
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
_pipelineEditorViewModel = _containerProvider.Resolve<PipelineEditorViewModel>();
_pipelineEditorView = new PipelineEditorView { DataContext = _pipelineEditorViewModel };
_mainViewportService.StateChanged += OnMainViewportStateChanged;
_cncEditorViewModel.PropertyChanged += (s, e) =>
@@ -310,12 +348,19 @@ namespace XplorePlane.ViewModels
OpenRealTimeLogViewerCommand = new DelegateCommand(ExecuteOpenRealTimeLogViewer);
UseLiveDetectorSourceCommand = new DelegateCommand(ExecuteUseLiveDetectorSource);
ImagePanelContent = new PipelineEditorView();
ImagePanelContent = _pipelineEditorView;
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
ImagePanelWidth = new GridLength(320);
DataRootPath = _xpDataPathService.RootPath;
LoadBuiltInInspectionModules();
_recordingService = recordingService ?? throw new ArgumentNullException(nameof(recordingService));
_recordingService.StateChanged += OnRecordingStateChanged;
_eventAggregator.GetEvent<ProvideCaptureTargetEvent>()
.Subscribe(OnCaptureTargetProvided, ThreadOption.UIThread);
ToggleRecordingCommand = new DelegateCommand(ExecuteToggleRecording, CanExecuteToggleRecording);
_logger.Info("MainViewModel 已初始化");
}
@@ -352,10 +397,31 @@ namespace XplorePlane.ViewModels
}
private void ExecuteOpenCncEditor()
{
_ = ExecuteOpenCncEditorAsync();
}
private async Task ExecuteOpenCncEditorAsync()
{
if (_isCncEditorMode)
{
ImagePanelContent = new PipelineEditorView();
// CNC → 普通模式:检查 CNC 程序是否有未保存修改
if (_cncEditorViewModel.IsModified)
{
var result = MessageBox.Show(
"CNC 程序有未保存的修改,是否保存?",
"未保存的修改",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Warning);
if (result == MessageBoxResult.Cancel)
return;
if (result == MessageBoxResult.Yes)
await _cncEditorViewModel.SaveAsync();
}
ImagePanelContent = _pipelineEditorView;
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
ImagePanelWidth = new GridLength(320);
_isCncEditorMode = false;
@@ -363,6 +429,22 @@ namespace XplorePlane.ViewModels
return;
}
// 普通 → CNC 模式:检查流水线是否有未保存修改
if (_pipelineEditorViewModel.IsModified)
{
var result = MessageBox.Show(
"图像处理流水线有未保存的修改,是否保存?",
"未保存的修改",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Warning);
if (result == MessageBoxResult.Cancel)
return;
if (result == MessageBoxResult.Yes)
await _pipelineEditorViewModel.SaveAsync();
}
ShowCncEditor();
}
@@ -959,5 +1041,111 @@ namespace XplorePlane.ViewModels
public sealed record BuiltInInspectionModuleItem(string DisplayName, string FilePath);
#endregion
#region
private bool CanExecuteToggleRecording()
{
// Idle 状态可启动,Recording 状态可停止,Saving 状态禁用
return _recordingService.CurrentState != RecordingState.Saving;
}
private void ExecuteToggleRecording()
{
if (_recordingService.CurrentState == RecordingState.Idle)
{
// 启动录制:captureTarget 将在 6.2 中通过事件传递
// 此处先触发事件让 View 提供 captureTarget
_eventAggregator.GetEvent<RequestCaptureTargetEvent>().Publish();
}
else if (_recordingService.CurrentState == RecordingState.Recording)
{
_recordingService.StopRecording();
}
}
private void OnCaptureTargetProvided(System.Windows.FrameworkElement captureTarget)
{
if (_recordingService.CurrentState == RecordingState.Idle)
{
bool started = _recordingService.StartRecording(captureTarget);
if (!started)
{
_logger.Warn("录制启动失败");
}
}
}
private void OnRecordingStateChanged(object? sender, RecordingStateChangedEventArgs e)
{
Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
{
IsRecording = e.NewState == RecordingState.Recording;
IsRecordingSaving = e.NewState == RecordingState.Saving;
RaisePropertyChanged(nameof(IsTimerOverlayVisible));
RaisePropertyChanged(nameof(CanRecord));
ToggleRecordingCommand.RaiseCanExecuteChanged();
if (e.NewState == RecordingState.Recording)
{
// 启动计时器更新 RecordingTimerText
StartRecordingTimer();
}
else
{
StopRecordingTimer();
RecordingTimerText = "00:00";
}
if (e.NewState == RecordingState.Idle && e.SavedFilePath != null)
{
// 保存成功,在状态栏显示文件路径(持续 5 秒)
ShowStatusMessage($"录制已保存:{e.SavedFilePath}", durationSeconds: 5);
}
else if (e.ErrorMessage != null)
{
ShowStatusMessage($"录制错误:{e.ErrorMessage}", durationSeconds: 5);
}
}));
}
private void StartRecordingTimer()
{
_statusMessageTimer?.Stop();
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
timer.Tick += (s, e) =>
{
if (_recordingService.CurrentState == RecordingState.Recording)
{
int totalSeconds = (int)_recordingService.ElapsedTime.TotalSeconds;
RecordingTimerText = ViewportRecordingService.FormatElapsedTime(totalSeconds);
}
};
timer.Start();
// Store in a dedicated field
_recordingTimerDispatcher = timer;
}
private void StopRecordingTimer()
{
_recordingTimerDispatcher?.Stop();
_recordingTimerDispatcher = null;
}
private void ShowStatusMessage(string message, int durationSeconds = 5)
{
StatusMessage = message;
_statusMessageTimer?.Stop();
_statusMessageTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(durationSeconds) };
_statusMessageTimer.Tick += (s, e) =>
{
StatusMessage = "就绪";
_statusMessageTimer?.Stop();
_statusMessageTimer = null;
};
_statusMessageTimer.Start();
}
#endregion
}
}
@@ -9,6 +9,7 @@ using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Recording;
namespace XplorePlane.ViewModels
{
@@ -21,6 +22,9 @@ namespace XplorePlane.ViewModels
private readonly IMainViewportService _mainViewportService;
private readonly IEventAggregator _eventAggregator;
private readonly IAppStateService _appStateService;
private readonly IViewportRecordingService _recordingService;
private bool _isTimerOverlayVisible;
private string _recordingTimerText = "00:00";
private ImageSource _imageSource;
private string _imageInfo = "等待探测器图像...";
@@ -42,12 +46,15 @@ namespace XplorePlane.ViewModels
IMainViewportService mainViewportService,
IEventAggregator eventAggregator,
IAppStateService appStateService,
ILoggerService logger)
ILoggerService logger,
IViewportRecordingService recordingService)
{
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
_logger = logger?.ForModule<ViewportPanelViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_recordingService = recordingService ?? throw new ArgumentNullException(nameof(recordingService));
_recordingService.StateChanged += OnRecordingStateChanged;
CancelMeasurementCommand = new DelegateCommand(ExecuteCancelMeasurement);
@@ -147,6 +154,20 @@ namespace XplorePlane.ViewModels
set => SetProperty(ref _measurementResult, value);
}
/// <summary>录制计时器覆盖层是否可见</summary>
public bool IsTimerOverlayVisible
{
get => _isTimerOverlayVisible;
private set => SetProperty(ref _isTimerOverlayVisible, value);
}
/// <summary>录制计时器文本(格式 MM:SS</summary>
public string RecordingTimerText
{
get => _recordingTimerText;
private set => SetProperty(ref _recordingTimerText, value);
}
public DelegateCommand CancelMeasurementCommand { get; }
// Task 5.5: ToggleRealtimeCommand
@@ -238,5 +259,25 @@ namespace XplorePlane.ViewModels
MessageBoxImage.Warning);
}
}
private void OnRecordingStateChanged(object? sender, RecordingStateChangedEventArgs e)
{
Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
{
IsTimerOverlayVisible = e.NewState == RecordingState.Recording;
if (e.NewState != RecordingState.Recording)
{
RecordingTimerText = "00:00";
}
}));
}
/// <summary>
/// 由 MainViewModel 的录制计时器调用,更新计时器文本
/// </summary>
public void UpdateRecordingTimerText(string timerText)
{
RecordingTimerText = timerText;
}
}
}
@@ -135,28 +135,77 @@
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<Border Width="28"
Height="28"
Background="#EEF2FF"
CornerRadius="4"
Margin="0,0,8,0">
<TextBlock Text="{Binding IconPath}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="14" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
FontFamily="Microsoft YaHei UI"
FontSize="11.5"
Foreground="#1c1c1b" />
<TextBlock Text="{Binding Key}"
FontFamily="Consolas"
FontSize="9.5"
Foreground="#999" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Border Width="28"
Height="28"
Background="#EEF2FF"
CornerRadius="4"
Margin="0,0,8,0">
<TextBlock Text="{Binding IconPath}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="14" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
FontFamily="Microsoft YaHei UI"
FontSize="11.5"
Foreground="#1c1c1b" />
<TextBlock Text="{Binding Key}"
FontFamily="Consolas"
FontSize="9.5"
Foreground="#999" />
</StackPanel>
</StackPanel>
</StackPanel>
<Button Grid.Column="1"
Width="24"
Height="24"
Margin="6,0,0,0"
VerticalAlignment="Center"
Cursor="Hand"
ToolTip="添加到流水线"
Command="{Binding DataContext.AddOperatorCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding Key}">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="2">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#C8DCFF" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#A0C0F0" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
<TextBlock Text="+"
FontSize="16"
FontWeight="Bold"
Foreground="#5B9BD5"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -117,6 +117,12 @@
FontSize="14"
Text="&#xE8E5;" />
</Button>
<Button DockPanel.Dock="Right"
Style="{StaticResource ToolbarBtn}"
ToolTip="实时">
<TextBlock FontFamily="Segoe MDL2 Assets" FontSize="14" Text="&#xE7B3;" />
</Button>
</StackPanel>
<TextBlock
@@ -148,7 +154,7 @@
<DataTemplate>
<Border
x:Name="NodeContainer"
Margin="2"
Margin="2,0"
Padding="2"
Tag="{Binding DataContext, ElementName=RootControl}"
Background="Transparent"
@@ -170,26 +176,14 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Line
x:Name="TopLine"
<!-- 贯穿整行的竖线,Margin负值延伸到ListBoxItem边界外,确保节点间无断点 -->
<Rectangle
x:Name="ConnectLine"
Width="2"
Margin="0,-4"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Stroke="#5B9BD5"
StrokeThickness="2"
X1="0"
X2="0"
Y1="0"
Y2="10" />
<Line
x:Name="BottomLine"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Stroke="#5B9BD5"
StrokeThickness="2"
X1="0"
X2="0"
Y1="0"
Y2="14" />
VerticalAlignment="Stretch"
Fill="#5B9BD5" />
<Border
x:Name="IconBorder"
@@ -226,14 +220,10 @@
</Grid>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Order}" Value="0">
<Setter TargetName="TopLine" Property="Visibility" Value="Collapsed" />
</DataTrigger>
<DataTrigger Binding="{Binding IsEnabled}" Value="False">
<Setter TargetName="NodeContainer" Property="Background" Value="{StaticResource DisabledNodeBg}" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="0.78" />
<Setter TargetName="TopLine" Property="Stroke" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="BottomLine" Property="Stroke" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="ConnectLine" Property="Fill" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="IconBorder" Property="BorderBrush" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="IconBorder" Property="Background" Value="#ECECEC" />
<Setter TargetName="NodeTitle" Property="Foreground" Value="{StaticResource DisabledNodeText}" />
@@ -249,8 +239,7 @@
<DataTrigger Binding="{Binding IsSkippedByExecutionRange}" Value="True">
<Setter TargetName="NodeContainer" Property="Background" Value="#FAFAFA" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="0.72" />
<Setter TargetName="TopLine" Property="Stroke" Value="#D0D0D0" />
<Setter TargetName="BottomLine" Property="Stroke" Value="#D0D0D0" />
<Setter TargetName="ConnectLine" Property="Fill" Value="#D0D0D0" />
<Setter TargetName="IconBorder" Property="BorderBrush" Value="#C8C8C8" />
<Setter TargetName="IconBorder" Property="Background" Value="#F4F4F4" />
<Setter TargetName="NodeTitle" Property="Foreground" Value="#909090" />
@@ -307,7 +296,7 @@
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="参数配置" />
Text="属性" />
<ItemsControl ItemsSource="{Binding SelectedNode.Parameters}">
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
@@ -347,36 +336,51 @@
</Style>
</Grid.Style>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="52" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="58" />
</Grid.ColumnDefinitions>
<Slider
<TextBox
Grid.Column="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsSnapToTickEnabled="{Binding IsIntegerSlider}"
LargeChange="{Binding SliderTickFrequency}"
Maximum="{Binding SliderMaximum}"
Minimum="{Binding SliderMinimum}"
SmallChange="{Binding SliderTickFrequency}"
TickFrequency="{Binding SliderTickFrequency}"
Value="{Binding SliderValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Border
Grid.Column="1"
Margin="0,0,6,0"
Padding="2,2"
Background="#F8F8F8"
VerticalAlignment="Center"
BorderBrush="#CDCBCB"
BorderThickness="1"
CornerRadius="2">
<TextBlock
HorizontalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding SliderValue, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<TextBlock
HorizontalAlignment="Left"
FontFamily="{StaticResource UiFont}"
FontSize="9"
Foreground="#999999"
Text="{Binding SliderMinimum}" />
<TextBlock
HorizontalAlignment="Right"
FontFamily="{StaticResource UiFont}"
FontSize="9"
Foreground="#999999"
Text="{Binding SliderMaximum}" />
</Grid>
<Slider
Grid.Row="1"
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding DisplayValueText}" />
</Border>
IsSnapToTickEnabled="{Binding IsIntegerSlider}"
LargeChange="{Binding SliderTickFrequency}"
Maximum="{Binding SliderMaximum}"
Minimum="{Binding SliderMinimum}"
SmallChange="{Binding SliderTickFrequency}"
TickFrequency="{Binding SliderTickFrequency}"
Value="{Binding SliderValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</Grid>
<TextBox
@@ -407,6 +411,7 @@
Grid.Column="1"
MinHeight="24"
Padding="4,1"
HorizontalContentAlignment="Center"
BorderBrush="#CDCBCB"
BorderThickness="1"
FontFamily="{StaticResource UiFont}"
+59 -37
View File
@@ -195,6 +195,26 @@
SmallImage="/Assets/Icons/dynamic-range.png"
Text="增强" />
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="录屏"
telerik:ScreenTip.Description="对实时图像区域进行录屏"
Command="{Binding ToggleRecordingCommand}"
Size="Medium"
SmallImage="/Assets/Icons/record.png"
Text="录屏">
<telerik:RadRibbonButton.Style>
<Style TargetType="telerik:RadRibbonButton">
<Style.Triggers>
<DataTrigger Binding="{Binding IsRecording}" Value="True">
<Setter Property="Background" Value="#FFCC0000" />
<Setter Property="Foreground" Value="White" />
</DataTrigger>
</Style.Triggers>
</Style>
</telerik:RadRibbonButton.Style>
</telerik:RadRibbonButton>
</StackPanel>
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="测量工具">
@@ -265,6 +285,26 @@
Size="Large"
SmallImage="/Assets/Icons/dynamic-range.png"
Text="算子工具箱" />
<StackPanel Width="170">
<TextBlock
Margin="0,0,0,4"
HorizontalAlignment="Center"
Text="内置检测模块" />
<telerik:RadRibbonComboBox
Width="160"
ItemsSource="{Binding BuiltInInspectionModules}"
DisplayMemberPath="DisplayName"
SelectedItem="{Binding SelectedBuiltInInspectionModule}"
IsEditable="False" />
<telerik:RadRibbonButton
Margin="0,4,0,0"
telerik:ScreenTip.Description="从 Tools 目录扫描到的 .xpm 中选择一个配方,并插入到当前 CNC 程序中"
telerik:ScreenTip.Title="插入内置检测模块"
Command="{Binding InsertBuiltInInspectionModuleCommand}"
Size="Medium"
SmallImage="/Assets/Icons/Module.png"
Text="插入模块" />
</StackPanel>
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="CNC">
@@ -286,26 +326,7 @@
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
<StackPanel Width="170">
<TextBlock
Margin="0,0,0,4"
HorizontalAlignment="Center"
Text="内置检测模块" />
<telerik:RadRibbonComboBox
Width="160"
ItemsSource="{Binding BuiltInInspectionModules}"
DisplayMemberPath="DisplayName"
SelectedItem="{Binding SelectedBuiltInInspectionModule}"
IsEditable="False" />
<telerik:RadRibbonButton
Margin="0,4,0,0"
telerik:ScreenTip.Description="从 Tools 目录扫描到的 .xpm 中选择一个配方,并插入到当前 CNC 程序中"
telerik:ScreenTip.Title="插入内置检测模块"
Command="{Binding InsertBuiltInInspectionModuleCommand}"
Size="Medium"
SmallImage="/Assets/Icons/Module.png"
Text="插入模块" />
</StackPanel>
<!--
<StackPanel>
<telerik:RadRibbonButton
@@ -382,32 +403,33 @@
SmallImage="/Assets/Icons/Pores.png" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="3D扫描">
<telerik:RadRibbonGroup Header="扫描模式" IsEnabled="{Binding Path=LinksGroup.IsEnabled}">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
</telerik:RadRibbonGroup.Variants>
<telerik:RadRibbonButton
telerik:ScreenTip.Description="Create a link in your document for quick access to webpages and files.&#13;&#13;Hyperlinks can also take you to places in your document."
telerik:ScreenTip.Title="Add a Hyperlink"
Command="{Binding Path=ShowHyperlinkDialog.Command}"
Content="快速扫描"
IsEnabled="{Binding Path=ShowHyperlinkDialog.IsEnabled}"
Size="Large"
SmallImage="/Assets/Icons/quick-scan.png" />
telerik:ScreenTip.Description="Create a link in your document for quick access to webpages and files.&#13;&#13;Hyperlinks can also take you to places in your document."
telerik:ScreenTip.Title="Add a Hyperlink"
Command="{Binding Path=ShowHyperlinkDialog.Command}"
Content="快速扫描"
IsEnabled="{Binding Path=ShowHyperlinkDialog.IsEnabled}"
Size="Large"
SmallImage="/Assets/Icons/quick-scan.png" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="Create a link in your document for quick access to webpages and files.&#13;&#13;Hyperlinks can also take you to places in your document."
telerik:ScreenTip.Title="Add a Hyperlink"
Command="{Binding Path=ShowHyperlinkDialog.Command}"
Content="螺旋扫描"
IsEnabled="{Binding Path=ShowHyperlinkDialog.IsEnabled}"
Size="Large"
SmallImage="/Assets/Icons/spiral.png" />
telerik:ScreenTip.Description="Create a link in your document for quick access to webpages and files.&#13;&#13;Hyperlinks can also take you to places in your document."
telerik:ScreenTip.Title="Add a Hyperlink"
Command="{Binding Path=ShowHyperlinkDialog.Command}"
Content="螺旋扫描"
IsEnabled="{Binding Path=ShowHyperlinkDialog.IsEnabled}"
Size="Large"
SmallImage="/Assets/Icons/spiral.png" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="操作">
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="设置">
<telerik:RadRibbonGroup Header="全局设置">
<telerik:RadRibbonGroup.Variants>
+20 -1
View File
@@ -10,7 +10,9 @@
d:DesignHeight="400"
d:DesignWidth="600"
mc:Ignorable="d">
<UserControl.Resources />
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
</UserControl.Resources>
<Grid Background="#FFFFFF">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -34,6 +36,23 @@
Background="White"
ImageSource="{Binding ImageSource}">
</roi:PolygonRoiCanvas>
<!-- 录制计时器覆盖层 -->
<Border
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="8,8,0,0"
Background="#AA000000"
CornerRadius="3"
Padding="6,2"
Visibility="{Binding IsTimerOverlayVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
Text="{Binding RecordingTimerText}"
FontFamily="Consolas"
FontSize="14"
FontWeight="Bold"
Foreground="White" />
</Border>
</Grid>
</Grid>
</UserControl>
@@ -111,6 +111,12 @@ namespace XplorePlane.Views
_ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None
};
}, Prism.Events.ThreadOption.UIThread);
// 录制请求:提供 RoiCanvas 作为 captureTarget
ea?.GetEvent<RequestCaptureTargetEvent>().Subscribe(() =>
{
ea.GetEvent<ProvideCaptureTargetEvent>().Publish(RoiCanvas);
}, Prism.Events.ThreadOption.UIThread);
}
catch { }
}