交运行工位桩:三口契约、运行服务、六态 VM、跨线程切 UI。
采集/PLC/Inspect 均为桩,验收 1–8 可测。不部署现场。
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<Application x:Class="VisionHost.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="Views/RunStationView.xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Station.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace VisionHost.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace VisionHost.App.Converters;
|
||||
|
||||
public sealed class HexToBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string hex || hex.Length < 7 || hex[0] != '#')
|
||||
return Brushes.White;
|
||||
try
|
||||
{
|
||||
return (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Brushes.White;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- 1920x1080 · 工位约 1.5m · 暗底高对比 -->
|
||||
<Color x:Key="BgColor">#1A1D21</Color>
|
||||
<Color x:Key="PanelColor">#252A31</Color>
|
||||
<Color x:Key="LineColor">#3A414B</Color>
|
||||
<Color x:Key="TextColor">#F2F4F7</Color>
|
||||
<Color x:Key="MutedColor">#9AA3AF</Color>
|
||||
<Color x:Key="OkColor">#22C55E</Color>
|
||||
<Color x:Key="NgColor">#EF4444</Color>
|
||||
<Color x:Key="AlarmColor">#F59E0B</Color>
|
||||
<Color x:Key="BusyColor">#38BDF8</Color>
|
||||
<Color x:Key="OfflineColor">#9CA3AF</Color>
|
||||
<Color x:Key="StartColor">#16A34A</Color>
|
||||
<Color x:Key="StopColor">#DC2626</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BgBrush" Color="{StaticResource BgColor}"/>
|
||||
<SolidColorBrush x:Key="PanelBrush" Color="{StaticResource PanelColor}"/>
|
||||
<SolidColorBrush x:Key="LineBrush" Color="{StaticResource LineColor}"/>
|
||||
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}"/>
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="{StaticResource MutedColor}"/>
|
||||
<SolidColorBrush x:Key="OkBrush" Color="{StaticResource OkColor}"/>
|
||||
<SolidColorBrush x:Key="NgBrush" Color="{StaticResource NgColor}"/>
|
||||
<SolidColorBrush x:Key="AlarmBrush" Color="{StaticResource AlarmColor}"/>
|
||||
<SolidColorBrush x:Key="BusyBrush" Color="{StaticResource BusyColor}"/>
|
||||
<SolidColorBrush x:Key="OfflineBrush" Color="{StaticResource OfflineColor}"/>
|
||||
<SolidColorBrush x:Key="StartBrush" Color="{StaticResource StartColor}"/>
|
||||
<SolidColorBrush x:Key="StopBrush" Color="{StaticResource StopColor}"/>
|
||||
|
||||
<Style x:Key="StationWindow" TargetType="Window">
|
||||
<Setter Property="Background" Value="{StaticResource BgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
|
||||
<Setter Property="FontSize" Value="18"/>
|
||||
<Setter Property="MinWidth" Value="1280"/>
|
||||
<Setter Property="MinHeight" Value="720"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TopBarText" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="FontSize" Value="22"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="BadgeText" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="48"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ShotText" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="48"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CountText" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="28"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="Margin" Value="0,8"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="AlarmText" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="20"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="RunButton" TargetType="Button">
|
||||
<Setter Property="Width" Value="160"/>
|
||||
<Setter Property="Height" Value="64"/>
|
||||
<Setter Property="FontSize" Value="22"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.35"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.8"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="StartButton" TargetType="Button" BasedOn="{StaticResource RunButton}">
|
||||
<Setter Property="Background" Value="{StaticResource StartBrush}"/>
|
||||
<Setter Property="Content" Value="启动"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="StopButton" TargetType="Button" BasedOn="{StaticResource RunButton}">
|
||||
<Setter Property="Background" Value="{StaticResource StopBrush}"/>
|
||||
<Setter Property="Content" Value="停止"/>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,137 @@
|
||||
<Window x:Class="VisionHost.App.Views.RunStationView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:cvt="clr-namespace:VisionHost.App.Converters"
|
||||
xmlns:rt="clr-namespace:VisionHost.Runtime;assembly=VisionHost.Runtime"
|
||||
Title="运行工位"
|
||||
Width="1920" Height="1080"
|
||||
WindowState="Maximized"
|
||||
Style="{StaticResource StationWindow}">
|
||||
<Window.Resources>
|
||||
<cvt:HexToBrushConverter x:Key="HexToBrush"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="72"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="88"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="0,0,0,1" Padding="24,0">
|
||||
<Grid>
|
||||
<TextBlock Style="{StaticResource TopBarText}">
|
||||
<Run Text="配方:"/><Run Text="{Binding RecipeName, FallbackValue=—}"/>
|
||||
</TextBlock>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Ellipse Width="14" Height="14" Margin="0,0,10,0" VerticalAlignment="Center">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Fill" Value="{StaticResource OfflineBrush}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ConnectionText}" Value="已连接">
|
||||
<Setter Property="Fill" Value="{StaticResource OkBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding ConnectionText}" Value="重连中">
|
||||
<Setter Property="Fill" Value="{StaticResource AlarmBrush}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
<TextBlock Text="{Binding ConnectionText, FallbackValue=未连接}" Style="{StaticResource TopBarText}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" Margin="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="360"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Background="#0B0D10" BorderBrush="{StaticResource LineBrush}" BorderThickness="1" Margin="0,0,16,0">
|
||||
<Grid>
|
||||
<ContentControl Content="{Binding LiveImage}" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ContentControl.Resources>
|
||||
<DataTemplate DataType="{x:Type rt:StubFrame}">
|
||||
<TextBlock FontSize="36" Foreground="{StaticResource MutedBrush}"
|
||||
Text="{Binding Seq, StringFormat=桩图 #{0}}"/>
|
||||
</DataTemplate>
|
||||
</ContentControl.Resources>
|
||||
</ContentControl>
|
||||
<ContentControl Content="{Binding OverlayImage}" IsHitTestVisible="False"/>
|
||||
<Border Width="200" Height="72" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="16"
|
||||
CornerRadius="4" Background="#CC0B0D10">
|
||||
<TextBlock Text="{Binding BadgeText, FallbackValue=就绪}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource BadgeText}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BadgeText}" Value="OK">
|
||||
<Setter Property="Foreground" Value="{StaticResource OkBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding BadgeText}" Value="NG">
|
||||
<Setter Property="Foreground" Value="{StaticResource NgBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding BadgeText}" Value="报警">
|
||||
<Setter Property="Foreground" Value="{StaticResource AlarmBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding BadgeText}" Value="检测中">
|
||||
<Setter Property="Foreground" Value="{StaticResource BusyBrush}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding BadgeText}" Value="断连">
|
||||
<Setter Property="Foreground" Value="{StaticResource OfflineBrush}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="1" Padding="24">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Text="本拍" Foreground="{StaticResource MutedBrush}" FontSize="18"/>
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="{Binding LastResultText, FallbackValue=—}"
|
||||
Style="{StaticResource ShotText}"
|
||||
Foreground="{Binding LastResultBrush, Converter={StaticResource HexToBrush}}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Row="2">
|
||||
<TextBlock Style="{StaticResource CountText}">
|
||||
<Run Text="OK "/><Run Text="{Binding OkCount, FallbackValue=0}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource CountText}">
|
||||
<Run Text="NG "/><Run Text="{Binding NgCount, FallbackValue=0}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource CountText}">
|
||||
<Run Text="合计 "/><Run Text="{Binding TotalCount, FallbackValue=0}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="2" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="0,1,0,0" Padding="24,0">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding AlarmText, FallbackValue=无报警}" Style="{StaticResource AlarmText}"
|
||||
Foreground="{Binding AlarmBrush, Converter={StaticResource HexToBrush}}"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Style="{StaticResource StartButton}"
|
||||
Command="{Binding StartCommand}"
|
||||
IsEnabled="{Binding CanStart}"
|
||||
Margin="0,0,16,0"/>
|
||||
<Button Style="{StaticResource StopButton}"
|
||||
Command="{Binding StopCommand}"
|
||||
IsEnabled="{Binding CanStop}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using VisionHost.Runtime;
|
||||
|
||||
namespace VisionHost.App.Views;
|
||||
|
||||
public partial class RunStationView : Window
|
||||
{
|
||||
public RunStationView()
|
||||
{
|
||||
InitializeComponent();
|
||||
var cam = new StubFrameSource();
|
||||
var run = new RunService(
|
||||
cam,
|
||||
cam,
|
||||
new StubInspectService(),
|
||||
new StubPlcIo(),
|
||||
new WpfUserConfirm());
|
||||
DataContext = new RunViewModel(run, new SynchronizationContextDispatch(SynchronizationContext.Current));
|
||||
Closed += (_, _) => (DataContext as RunViewModel)?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<RootNamespace>VisionHost.App</RootNamespace>
|
||||
<AssemblyName>VisionHost.App</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VisionHost.Runtime\VisionHost.Runtime.csproj" />
|
||||
<ProjectReference Include="..\VisionHost.Contracts\VisionHost.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.App;
|
||||
|
||||
public sealed class WpfUserConfirm : IUserConfirm
|
||||
{
|
||||
public bool ConfirmStart() => Ask("确认启动检测?");
|
||||
public bool ConfirmStop() => Ask("确认停止检测?");
|
||||
|
||||
private static bool Ask(string message)
|
||||
{
|
||||
var w = new Window
|
||||
{
|
||||
Width = 420,
|
||||
Height = 180,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ResizeMode = ResizeMode.NoResize,
|
||||
WindowStyle = WindowStyle.None,
|
||||
Background = (System.Windows.Media.Brush)Application.Current.FindResource("PanelBrush"),
|
||||
Owner = Application.Current.MainWindow
|
||||
};
|
||||
var grid = new Grid { Margin = new Thickness(24) };
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
var text = new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
FontSize = 20,
|
||||
Foreground = (System.Windows.Media.Brush)Application.Current.FindResource("TextBrush"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
Grid.SetRow(text, 0);
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||
var ok = new Button
|
||||
{
|
||||
Content = "确认",
|
||||
Style = (Style)Application.Current.FindResource("RunButton"),
|
||||
Background = (System.Windows.Media.Brush)Application.Current.FindResource("StartBrush"),
|
||||
IsDefault = true,
|
||||
Margin = new Thickness(0, 0, 12, 0)
|
||||
};
|
||||
var cancel = new Button
|
||||
{
|
||||
Content = "取消",
|
||||
Style = (Style)Application.Current.FindResource("RunButton"),
|
||||
Background = (System.Windows.Media.Brush)Application.Current.FindResource("LineBrush"),
|
||||
IsCancel = true
|
||||
};
|
||||
var accepted = false;
|
||||
ok.Click += (_, _) => { accepted = true; w.Close(); };
|
||||
cancel.Click += (_, _) => w.Close();
|
||||
buttons.Children.Add(ok);
|
||||
buttons.Children.Add(cancel);
|
||||
Grid.SetRow(buttons, 1);
|
||||
grid.Children.Add(text);
|
||||
grid.Children.Add(buttons);
|
||||
w.Content = grid;
|
||||
w.ShowDialog();
|
||||
return accepted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public enum ConnectionState
|
||||
{
|
||||
Disconnected,
|
||||
Connected,
|
||||
Reconnecting
|
||||
}
|
||||
|
||||
public sealed record DeviceStatusInfo(
|
||||
ConnectionState Connection,
|
||||
string? Detail);
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public sealed record FrameReadyEvent(
|
||||
string CameraId,
|
||||
object Frame,
|
||||
DateTimeOffset Timestamp);
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public interface IDeviceStatus
|
||||
{
|
||||
event Action<DeviceStatusInfo>? Changed;
|
||||
DeviceStatusInfo Current { get; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public interface IFrameSource
|
||||
{
|
||||
event Action<FrameReadyEvent>? FrameReady;
|
||||
ConnectionState Connection { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public interface IInspectService
|
||||
{
|
||||
Task<InspectResult> Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public interface IPlcIo
|
||||
{
|
||||
event Action<DeviceStatusInfo>? StatusChanged;
|
||||
Task WriteAsync(string tag, object value, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
Task<object?> ReadAsync(string tag, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public interface IUiDispatch
|
||||
{
|
||||
void Post(Action action);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public interface IUserConfirm
|
||||
{
|
||||
bool ConfirmStart();
|
||||
bool ConfirmStop();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public sealed record InspectResult(
|
||||
bool Ok,
|
||||
string? NgCode,
|
||||
int ElapsedMs,
|
||||
object? Overlay);
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public enum RunState
|
||||
{
|
||||
Idle,
|
||||
Busy,
|
||||
Ok,
|
||||
Ng,
|
||||
Alarm,
|
||||
Disconnected
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
namespace VisionHost.Contracts;
|
||||
|
||||
public sealed class RuntimeOptions
|
||||
{
|
||||
public int QueueCapacity { get; set; } = 8;
|
||||
public int InspectTimeoutMs { get; set; } = 2000;
|
||||
public int PlcTimeoutMs { get; set; } = 500;
|
||||
public string RecipeId { get; set; } = "STUB";
|
||||
public string RecipeName { get; set; } = "STUB";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>VisionHost.Contracts</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed record Alarm(string Text, DateTimeOffset RaisedAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class AlwaysConfirm : IUserConfirm
|
||||
{
|
||||
public bool ConfirmStart() => true;
|
||||
public bool ConfirmStop() => true;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class RunService : IDisposable
|
||||
{
|
||||
public const string OkBrush = "#22C55E";
|
||||
public const string NgBrush = "#EF4444";
|
||||
public const string AlarmBrushHex = "#F59E0B";
|
||||
public const string TextBrush = "#F2F4F7";
|
||||
public const string MutedBrush = "#9AA3AF";
|
||||
|
||||
private readonly IFrameSource _frames;
|
||||
private readonly IDeviceStatus _devices;
|
||||
private readonly IInspectService _inspect;
|
||||
private readonly IPlcIo _plc;
|
||||
private readonly IUserConfirm _confirm;
|
||||
private readonly RuntimeOptions _opt;
|
||||
private readonly ConcurrentQueue<FrameReadyEvent> _queue = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly CancellationTokenSource _life = new();
|
||||
|
||||
private bool _running;
|
||||
private bool _busy;
|
||||
private int _ok;
|
||||
private int _ng;
|
||||
private InspectResult? _last;
|
||||
private object? _live;
|
||||
private Alarm? _alarm;
|
||||
private DeviceStatusInfo _device;
|
||||
private CancellationTokenSource? _runCts;
|
||||
private Task? _worker;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public RunService(
|
||||
IFrameSource frames,
|
||||
IDeviceStatus devices,
|
||||
IInspectService inspect,
|
||||
IPlcIo plc,
|
||||
IUserConfirm confirm,
|
||||
RuntimeOptions? options = null)
|
||||
{
|
||||
_frames = frames;
|
||||
_devices = devices;
|
||||
_inspect = inspect;
|
||||
_plc = plc;
|
||||
_confirm = confirm;
|
||||
_opt = options ?? new RuntimeOptions();
|
||||
_device = devices.Current;
|
||||
|
||||
_frames.FrameReady += OnFrame;
|
||||
_devices.Changed += OnDevice;
|
||||
_plc.StatusChanged += OnPlc;
|
||||
}
|
||||
|
||||
public StationSnapshot Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var state = ComputeState();
|
||||
var lastText = FormatLast(_last);
|
||||
var alarmText = FormatAlarm();
|
||||
var connected = _device.Connection == ConnectionState.Connected;
|
||||
return new StationSnapshot(
|
||||
RunState: state,
|
||||
RecipeName: _opt.RecipeName,
|
||||
ConnectionText: _device.Connection switch
|
||||
{
|
||||
ConnectionState.Connected => "已连接",
|
||||
ConnectionState.Reconnecting => "重连中",
|
||||
_ => "未连接"
|
||||
},
|
||||
LiveImage: _live,
|
||||
OverlayImage: _last?.Overlay,
|
||||
BadgeText: state switch
|
||||
{
|
||||
RunState.Busy => "检测中",
|
||||
RunState.Ok => "OK",
|
||||
RunState.Ng => "NG",
|
||||
RunState.Alarm => "报警",
|
||||
RunState.Disconnected => "断连",
|
||||
_ => "就绪"
|
||||
},
|
||||
LastResultText: lastText,
|
||||
LastResultBrush: lastText.StartsWith("NG", StringComparison.Ordinal) ? NgBrush
|
||||
: lastText == "OK" ? OkBrush
|
||||
: TextBrush,
|
||||
OkCount: _ok,
|
||||
NgCount: _ng,
|
||||
TotalCount: _ok + _ng,
|
||||
AlarmText: alarmText,
|
||||
AlarmBrush: alarmText == "无报警" ? MutedBrush : AlarmBrushHex,
|
||||
CanStart: connected && !_running,
|
||||
CanStop: _running,
|
||||
IsRunning: _running);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RequestStart()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_running) return false;
|
||||
if (_device.Connection != ConnectionState.Connected) return false;
|
||||
}
|
||||
|
||||
if (!_confirm.ConfirmStart()) return false;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_running) return false;
|
||||
if (_device.Connection != ConnectionState.Connected) return false;
|
||||
_ok = 0;
|
||||
_ng = 0;
|
||||
_last = null;
|
||||
_alarm = null;
|
||||
_busy = false;
|
||||
_running = true;
|
||||
_runCts = CancellationTokenSource.CreateLinkedTokenSource(_life.Token);
|
||||
}
|
||||
|
||||
if (_frames is StubFrameSource stub)
|
||||
stub.StartGrab();
|
||||
|
||||
_worker = Task.Run(() => PumpAsync(_runCts.Token));
|
||||
Raise();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RequestStop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_running) return false;
|
||||
}
|
||||
|
||||
if (!_confirm.ConfirmStop()) return false;
|
||||
StopCore();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(string text)
|
||||
{
|
||||
lock (_gate) _alarm = new Alarm(text, DateTimeOffset.UtcNow);
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void ClearAlarm()
|
||||
{
|
||||
lock (_gate) _alarm = null;
|
||||
Raise();
|
||||
}
|
||||
|
||||
private void StopCore()
|
||||
{
|
||||
CancellationTokenSource? cts;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_running) return;
|
||||
_running = false;
|
||||
_busy = false;
|
||||
cts = _runCts;
|
||||
_runCts = null;
|
||||
}
|
||||
|
||||
if (_frames is StubFrameSource stub)
|
||||
stub.StopGrab();
|
||||
|
||||
try { cts?.Cancel(); } catch (ObjectDisposedException) { }
|
||||
cts?.Dispose();
|
||||
Raise();
|
||||
}
|
||||
|
||||
private void OnFrame(FrameReadyEvent e)
|
||||
{
|
||||
if (!Snapshot().IsRunning && !_running) return;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_running) return;
|
||||
_live = e.Frame;
|
||||
if (_queue.Count >= _opt.QueueCapacity)
|
||||
{
|
||||
_queue.TryDequeue(out _);
|
||||
_alarm = new Alarm("采集队列已满,已丢最旧帧", DateTimeOffset.UtcNow);
|
||||
}
|
||||
_queue.Enqueue(e);
|
||||
}
|
||||
|
||||
Raise();
|
||||
}
|
||||
|
||||
private void OnDevice(DeviceStatusInfo info)
|
||||
{
|
||||
lock (_gate) _device = info;
|
||||
if (info.Connection != ConnectionState.Connected)
|
||||
StopCore();
|
||||
else
|
||||
Raise();
|
||||
}
|
||||
|
||||
private void OnPlc(DeviceStatusInfo info)
|
||||
{
|
||||
if (info.Connection == ConnectionState.Disconnected)
|
||||
RaiseAlarm(info.Detail ?? "PLC 断连");
|
||||
}
|
||||
|
||||
private async Task PumpAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (!_queue.TryDequeue(out var frame))
|
||||
{
|
||||
try { await Task.Delay(15, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (_gate) _busy = true;
|
||||
Raise();
|
||||
|
||||
InspectResult result;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(_opt.InspectTimeoutMs);
|
||||
result = await _inspect.Inspect(frame.Frame, _opt.RecipeId, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
result = new InspectResult(false, "TIMEOUT", _opt.InspectTimeoutMs, null);
|
||||
lock (_gate) _alarm = new Alarm("检测超时", DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new InspectResult(false, "ERR", 0, null);
|
||||
lock (_gate) _alarm = new Alarm(ex.Message, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_last = result;
|
||||
_busy = false;
|
||||
if (result.Ok) _ok++;
|
||||
else _ng++;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _plc.WriteAsync(
|
||||
"Result.Ok",
|
||||
result.Ok,
|
||||
TimeSpan.FromMilliseconds(_opt.PlcTimeoutMs),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RaiseAlarm("PLC 写出失败: " + ex.Message);
|
||||
}
|
||||
|
||||
Raise();
|
||||
}
|
||||
|
||||
lock (_gate) _busy = false;
|
||||
Raise();
|
||||
}
|
||||
|
||||
private RunState ComputeState()
|
||||
{
|
||||
if (_device.Connection != ConnectionState.Connected)
|
||||
return RunState.Disconnected;
|
||||
if (_alarm is not null)
|
||||
return RunState.Alarm;
|
||||
if (_busy)
|
||||
return RunState.Busy;
|
||||
if (_last is { Ok: true })
|
||||
return RunState.Ok;
|
||||
if (_last is { Ok: false })
|
||||
return RunState.Ng;
|
||||
return RunState.Idle;
|
||||
}
|
||||
|
||||
private static string FormatLast(InspectResult? r)
|
||||
{
|
||||
if (r is null) return "—";
|
||||
if (r.Ok) return "OK";
|
||||
return string.IsNullOrWhiteSpace(r.NgCode) ? "NG" : "NG " + r.NgCode;
|
||||
}
|
||||
|
||||
private string FormatAlarm()
|
||||
{
|
||||
if (_device.Connection == ConnectionState.Disconnected)
|
||||
return _device.Detail ?? "相机未连接";
|
||||
if (_device.Connection == ConnectionState.Reconnecting)
|
||||
return _device.Detail ?? "相机重连中";
|
||||
return _alarm?.Text ?? "无报警";
|
||||
}
|
||||
|
||||
private void Raise() => Changed?.Invoke();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_frames.FrameReady -= OnFrame;
|
||||
_devices.Changed -= OnDevice;
|
||||
_plc.StatusChanged -= OnPlc;
|
||||
StopCore();
|
||||
_life.Cancel();
|
||||
_life.Dispose();
|
||||
if (_frames is IDisposable d) d.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action _exec;
|
||||
private readonly Func<bool>? _can;
|
||||
|
||||
public RelayCommand(Action exec, Func<bool>? can = null)
|
||||
{
|
||||
_exec = exec;
|
||||
_can = can;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
public bool CanExecute(object? parameter) => _can?.Invoke() ?? true;
|
||||
public void Execute(object? parameter) => _exec();
|
||||
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public sealed class RunViewModel : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
private readonly RunService _run;
|
||||
private readonly IUiDispatch _ui;
|
||||
private StationSnapshot _s;
|
||||
private int _disposed;
|
||||
|
||||
public RunViewModel(RunService run, IUiDispatch? ui = null)
|
||||
{
|
||||
_run = run;
|
||||
_ui = ui ?? new SynchronizationContextDispatch();
|
||||
_s = run.Snapshot();
|
||||
StartCommand = new RelayCommand(() => _run.RequestStart(), () => CanStart);
|
||||
StopCommand = new RelayCommand(() => _run.RequestStop(), () => CanStop);
|
||||
_run.Changed += OnChanged;
|
||||
}
|
||||
|
||||
public static RunViewModel CreateStub(IUserConfirm? confirm = null, IUiDispatch? ui = null)
|
||||
{
|
||||
var cam = new StubFrameSource();
|
||||
var run = new RunService(
|
||||
cam,
|
||||
cam,
|
||||
new StubInspectService(),
|
||||
new StubPlcIo(),
|
||||
confirm ?? new AlwaysConfirm(),
|
||||
new RuntimeOptions());
|
||||
return new RunViewModel(run, ui);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public ICommand StartCommand { get; }
|
||||
public ICommand StopCommand { get; }
|
||||
|
||||
public RunState RunState => _s.RunState;
|
||||
public string RecipeName => _s.RecipeName;
|
||||
public string ConnectionText => _s.ConnectionText;
|
||||
public object? LiveImage => _s.LiveImage;
|
||||
public object? OverlayImage => _s.OverlayImage;
|
||||
public string BadgeText => _s.BadgeText;
|
||||
public string LastResultText => _s.LastResultText;
|
||||
public string LastResultBrush => _s.LastResultBrush;
|
||||
public int OkCount => _s.OkCount;
|
||||
public int NgCount => _s.NgCount;
|
||||
public int TotalCount => _s.TotalCount;
|
||||
public string AlarmText => _s.AlarmText;
|
||||
public string AlarmBrush => _s.AlarmBrush;
|
||||
public bool CanStart => _s.CanStart;
|
||||
public bool CanStop => _s.CanStop;
|
||||
|
||||
public RunService Service => _run;
|
||||
|
||||
private void OnChanged() => _ui.Post(ApplyOnUi);
|
||||
|
||||
private void ApplyOnUi()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) return;
|
||||
_s = _run.Snapshot();
|
||||
OnPropertyChanged(string.Empty);
|
||||
((RelayCommand)StartCommand).RaiseCanExecuteChanged();
|
||||
((RelayCommand)StopCommand).RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_run.Changed -= OnChanged;
|
||||
_run.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class ScriptedConfirm : IUserConfirm
|
||||
{
|
||||
private readonly Queue<bool> _start;
|
||||
private readonly Queue<bool> _stop;
|
||||
|
||||
public ScriptedConfirm(IEnumerable<bool>? start = null, IEnumerable<bool>? stop = null)
|
||||
{
|
||||
_start = new Queue<bool>(start ?? Array.Empty<bool>());
|
||||
_stop = new Queue<bool>(stop ?? Array.Empty<bool>());
|
||||
}
|
||||
|
||||
public int StartPrompts { get; private set; }
|
||||
public int StopPrompts { get; private set; }
|
||||
|
||||
public bool ConfirmStart()
|
||||
{
|
||||
StartPrompts++;
|
||||
return _start.Count == 0 || _start.Dequeue();
|
||||
}
|
||||
|
||||
public bool ConfirmStop()
|
||||
{
|
||||
StopPrompts++;
|
||||
return _stop.Count == 0 || _stop.Dequeue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed record StationSnapshot(
|
||||
RunState RunState,
|
||||
string RecipeName,
|
||||
string ConnectionText,
|
||||
object? LiveImage,
|
||||
object? OverlayImage,
|
||||
string BadgeText,
|
||||
string LastResultText,
|
||||
string LastResultBrush,
|
||||
int OkCount,
|
||||
int NgCount,
|
||||
int TotalCount,
|
||||
string AlarmText,
|
||||
string AlarmBrush,
|
||||
bool CanStart,
|
||||
bool CanStop,
|
||||
bool IsRunning);
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class StubFrameSource : IFrameSource, IDeviceStatus, IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private ConnectionState _connection = ConnectionState.Connected;
|
||||
private Timer? _timer;
|
||||
private int _frameSeq;
|
||||
|
||||
public event Action<FrameReadyEvent>? FrameReady;
|
||||
public event Action<DeviceStatusInfo>? Changed;
|
||||
|
||||
public ConnectionState Connection
|
||||
{
|
||||
get { lock (_gate) return _connection; }
|
||||
}
|
||||
|
||||
public DeviceStatusInfo Current => new(Connection, Detail());
|
||||
|
||||
public void SetConnection(ConnectionState state)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_connection == state) return;
|
||||
_connection = state;
|
||||
if (state != ConnectionState.Connected)
|
||||
StopGrab();
|
||||
}
|
||||
|
||||
Changed?.Invoke(Current);
|
||||
}
|
||||
|
||||
public void StartGrab(int intervalMs = 200)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_connection != ConnectionState.Connected) return;
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(_ => Emit(), null, 0, intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
public void StopGrab()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Emit()
|
||||
{
|
||||
if (Connection != ConnectionState.Connected) return;
|
||||
var n = Interlocked.Increment(ref _frameSeq);
|
||||
FrameReady?.Invoke(new FrameReadyEvent("CAM0", new StubFrame(n), DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private string? Detail() => Connection switch
|
||||
{
|
||||
ConnectionState.Disconnected => "相机未连接",
|
||||
ConnectionState.Reconnecting => "相机重连中",
|
||||
_ => null
|
||||
};
|
||||
|
||||
public void Dispose() => StopGrab();
|
||||
}
|
||||
|
||||
public sealed record StubFrame(int Seq);
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
using System.Diagnostics;
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
/// <summary>算法桩:拍号在服务内计,VM 不数拍。1-4 OK,第 5 拍 NG/STUB,循环。</summary>
|
||||
public sealed class StubInspectService : IInspectService
|
||||
{
|
||||
private int _shot;
|
||||
|
||||
public Task<InspectResult> Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var n = Interlocked.Increment(ref _shot);
|
||||
var isNg = n % 5 == 0;
|
||||
sw.Stop();
|
||||
return Task.FromResult(isNg
|
||||
? new InspectResult(false, "STUB", (int)sw.ElapsedMilliseconds, Overlay: null)
|
||||
: new InspectResult(true, null, (int)sw.ElapsedMilliseconds, Overlay: null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class StubPlcIo : IPlcIo
|
||||
{
|
||||
public event Action<DeviceStatusInfo>? StatusChanged;
|
||||
|
||||
public Task WriteAsync(string tag, object value, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<object?> ReadAsync(string tag, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<object?>(null);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using VisionHost.Contracts;
|
||||
|
||||
namespace VisionHost.Runtime;
|
||||
|
||||
public sealed class SynchronizationContextDispatch : IUiDispatch
|
||||
{
|
||||
private readonly SynchronizationContext? _ctx;
|
||||
|
||||
public SynchronizationContextDispatch(SynchronizationContext? ctx = null)
|
||||
{
|
||||
_ctx = ctx ?? SynchronizationContext.Current;
|
||||
}
|
||||
|
||||
public void Post(Action action)
|
||||
{
|
||||
if (_ctx is null)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
_ctx.Post(_ => action(), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>VisionHost.Runtime</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VisionHost.Contracts\VisionHost.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user