Compare commits
5 Commits
a4e257e8ce
...
318d1813b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 318d1813b8 | |||
| 8500f8b5ed | |||
| 3c9b3a2731 | |||
| aa39f8ca95 | |||
| 964284d4b1 |
@@ -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 (00–59) and SS is zero-padded seconds (00–59),
|
||||
/// 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="" />
|
||||
</Button>
|
||||
|
||||
<Button DockPanel.Dock="Right"
|
||||
Style="{StaticResource ToolbarBtn}"
|
||||
ToolTip="实时">
|
||||
<TextBlock FontFamily="Segoe MDL2 Assets" FontSize="14" Text="" />
|
||||
</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}"
|
||||
|
||||
@@ -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. 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. 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. 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. 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>
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user