feat: 레시피 화면에서 스캐너 카메라+모션 토글 창 추가 (이벤트 구동)

- 레시피 컨트롤박스에 'Scanner Cam' 버튼 추가 → 카메라+모션 창 Show/Hide 토글
- WDScannerCamMotion: 전용 displayType(Machine_C) 카메라뷰 + 매뉴얼 UCScannerMotion 재사용
- UCScannerMotionViewModel: publish/execute 모드 분리
  - 레시피(publish): 입력값을 EVENT_SCANNER_COMMAND_MOVE 로 발행
  - 매뉴얼(execute): 이벤트 구독해 동일 메소드로 실제 스캐너 이동(인터락 그대로 재사용)
- Move/Jog/Home 메소드를 인자형으로 리팩터해 UI·이벤트 경로가 같은 본체 공유
- 분석 문서 추가(docs/분석/2026/06/23)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
노도연 2026-06-23 17:03:21 +09:00
parent 020840e678
commit 4ed35cb94a
8 changed files with 454 additions and 51 deletions

View File

@ -144,6 +144,13 @@ namespace WeldingCommon
public const string EVENT_CHANGE_DOOR_LOCK = "EventChangeDoorLock";
public const string EVENT_CHANGE_RECIPE_INFO = "EventChangeRecipeInfo";
public const string EVENT_AUTO_RUN_UPDATE_PAGENUM = "EventAutoRunUpdatePageNum";
// 레시피 화면(publish) → 매뉴얼 스캐너 VM(subscribe)로 스캐너 모션 요청을 전달한다.
// Param: WeldingCommon.Views.ScannerCommandMoveParam
// 주의: 구독자(UCScannerMotionViewModel, execute 모드)는 RegisterViews에서 기동 시 1회 생성되어
// 프로세스 내내 생존하는 전제(EquipmentSetup.RegisterViews)에 의존한다. View 생성을
// 지연 생성으로 바꾸면 구독자가 없어 본 이벤트가 무시될 수 있으므로 함께 점검할 것.
public const string EVENT_SCANNER_COMMAND_MOVE = "EventScannerCommandMove";
}
public class CommonSequenceIds

View File

@ -11,6 +11,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Reactive.Linq;
using System.Windows.Input;
using System.Windows.Threading;
@ -34,6 +35,29 @@ namespace WeldingCommon.Views
REL
};
public enum eScannerMoveKind
{
CommandPos,
Jog,
Home
}
/// <summary>
/// 레시피 화면(publish 모드)에서 매뉴얼 스캐너 VM(execute 모드)으로 보내는 모션 요청 페이로드.
/// 매뉴얼 VM의 저장 state가 아니라 이 값으로 동작하도록 모든 입력값을 담는다.
/// </summary>
public class ScannerCommandMoveParam
{
public eScannerMoveKind Kind;
public eAxisType Axis;
public eJogDirection Direction;
public double CommandPosX;
public double CommandPosY;
public double StepValue;
public eMotionMode Mode;
public double VelocityRate;
}
public class UCScannerMotionViewModel : NotifyPropertyChangedBase
{
private readonly Logger Log = new Logger("UCScannerMotionViewModel");
@ -80,12 +104,17 @@ namespace WeldingCommon.Views
private double _VelocityRate;
private eMotionMode _MotionMode;
// publish 모드: UI(레시피)는 직접 디바이스를 제어하지 않고, 입력값을 이벤트로 발행한다.
// execute 모드(기본, 매뉴얼): 이벤트를 구독해 실제 스캐너를 움직인다.
private readonly bool _PublishMode;
private readonly DispatcherTimer _MonitorTimer;
// PAM, RUW 처럼 2개 이상의 scanner 를 사용하는 경우가 있어, scannerId 를 받는 형태로 생성
public UCScannerMotionViewModel(IAuroraEnvironment aurora, string scannerId)
public UCScannerMotionViewModel(IAuroraEnvironment aurora, string scannerId, bool publishMode = false)
{
_Aurora = aurora;
_PublishMode = publishMode;
_Scanner = _Aurora.DeviceManager[scannerId] as Scanner;
_Laser = _Aurora.DeviceManager[CommonDeviceIds.Laser] as Laser;
@ -99,11 +128,52 @@ namespace WeldingCommon.Views
CMD_JogMove = new DelegateCommand(PerformCmdJogMove);
CMD_CommandPosMove = new DelegateCommand(PerformCmdCommandPosMove);
// execute 모드만 원격 모션 요청을 구독한다(중복 실행 방지). UI 스레드에서 처리.
if (!_PublishMode) {
_Aurora.EventObservable.AsObservable(CommonEventIds.EVENT_SCANNER_COMMAND_MOVE)
.ObserveOnDispatcher()
.Subscribe(OnRemoteMotionRequested);
}
_MonitorTimer = new DispatcherTimer();
_MonitorTimer.Tick += OnMonitoring;
_MonitorTimer.Interval = TimeSpan.FromMilliseconds(100);
}
private void PublishMotion(ScannerCommandMoveParam param)
{
_Aurora.EventPublisher.Publish(
new EventMessage("UCScannerMotionViewModel.Publish", CommonEventIds.EVENT_SCANNER_COMMAND_MOVE, param));
}
private void OnRemoteMotionRequested(EventMessage msg)
{
if (msg == null || !(msg.Param is ScannerCommandMoveParam p)) {
return;
}
switch (p.Kind) {
case eScannerMoveKind.CommandPos:
MoveCommandXYPos(p.Axis, p.CommandPosX, p.CommandPosY, p.Mode, p.VelocityRate);
break;
case eScannerMoveKind.Jog:
if (IsMainSequenceRunning()) return;
MoveJogXYPos(p.Axis, p.Direction, p.StepValue, p.VelocityRate);
break;
case eScannerMoveKind.Home:
if (IsMainSequenceRunning()) return;
MoveJogHome(p.VelocityRate);
break;
}
}
private bool IsMainSequenceRunning()
{
return _Aurora.SequenceEntry[CommonSequenceIds.SeqAutoRun].RunState == SequenceRunState.Running;
}
private void InitVariable()
{
_VelocityRate = 1;
@ -190,49 +260,56 @@ namespace WeldingCommon.Views
{
// TODO: Left, Right, Up, Down 은 장비 Scanner 의 방향에 따라 달라질 수 있다
// 양산중에는 동작 하면 안됨
var MainSequence = _Aurora.SequenceEntry[CommonSequenceIds.SeqAutoRun].RunState;
if(MainSequence == SequenceRunState.Running)
if (IsMainSequenceRunning())
return;
eAxisType axis;
eJogDirection direction;
switch ((string)commandParameter) {
case "Left": {
MoveJogXYPos(eAxisType.AXIS_X, eJogDirection.MINUS);
case "Left": axis = eAxisType.AXIS_X; direction = eJogDirection.MINUS; break;
case "Right": axis = eAxisType.AXIS_X; direction = eJogDirection.PLUS; break;
case "Up": axis = eAxisType.AXIS_Y; direction = eJogDirection.PLUS; break;
case "Down": axis = eAxisType.AXIS_Y; direction = eJogDirection.MINUS; break;
case "Home":
if (_PublishMode) {
PublishMotion(new ScannerCommandMoveParam {
Kind = eScannerMoveKind.Home,
VelocityRate = _VelocityRate,
});
} else {
MoveJogHome(_VelocityRate);
}
break;
return;
case "Right": {
MoveJogXYPos(eAxisType.AXIS_X, eJogDirection.PLUS);
}
break;
case "Up": {
MoveJogXYPos(eAxisType.AXIS_Y, eJogDirection.PLUS);
}
break;
case "Down": {
MoveJogXYPos(eAxisType.AXIS_Y, eJogDirection.MINUS);
}
break;
case "Home": {
MoveJogHome();
}
break;
}
default:
return;
}
private void MoveJogXYPos(eAxisType axisType, eJogDirection direction)
if (_PublishMode) {
PublishMotion(new ScannerCommandMoveParam {
Kind = eScannerMoveKind.Jog,
Axis = axis,
Direction = direction,
StepValue = StepValue,
VelocityRate = _VelocityRate,
});
return;
}
MoveJogXYPos(axis, direction, StepValue, _VelocityRate);
}
private void MoveJogXYPos(eAxisType axisType, eJogDirection direction, double stepValue, double velocityRate)
{
double xPos = 0.0;
double yPos = 0.0;
switch (axisType) {
case eAxisType.AXIS_X: {
if (direction == eJogDirection.MINUS) {
xPos = StepValue * -1;
xPos = stepValue * -1;
yPos = 0;
} else if (direction == eJogDirection.PLUS) {
xPos = StepValue;
xPos = stepValue;
yPos = 0;
}
}
@ -241,10 +318,10 @@ namespace WeldingCommon.Views
case eAxisType.AXIS_Y: {
if (direction == eJogDirection.MINUS) {
xPos = 0;
yPos = StepValue * -1;
yPos = stepValue * -1;
} else if (direction == eJogDirection.PLUS) {
xPos = 0;
yPos = StepValue;
yPos = stepValue;
}
}
break;
@ -259,7 +336,7 @@ namespace WeldingCommon.Views
return;
}
double finalVelocity = _ScannerConfig.JumpSpeed * _VelocityRate;
double finalVelocity = _ScannerConfig.JumpSpeed * velocityRate;
_Scanner.SetJumpSpeedCtrl(finalVelocity);
_Scanner.JumpRel(xPos, yPos);
@ -267,9 +344,9 @@ namespace WeldingCommon.Views
_Scanner.SetJumpSpeedCtrl(_ScannerConfig.JumpSpeed);
}
private void MoveJogHome()
private void MoveJogHome(double velocityRate)
{
double finalVelocity = _ScannerConfig.JumpSpeed * _VelocityRate;
double finalVelocity = _ScannerConfig.JumpSpeed * velocityRate;
_Scanner.SetJumpSpeedCtrl(finalVelocity);
_Scanner.JumpAbs(0, 0);
@ -279,28 +356,37 @@ namespace WeldingCommon.Views
private void PerformCmdCommandPosMove(object commandParameter)
{
eAxisType axis;
switch ((string)commandParameter) {
case "X": {
MoveCommandXYPos(eAxisType.AXIS_X);
}
break;
case "Y": {
MoveCommandXYPos(eAxisType.AXIS_Y);
}
break;
}
case "X": axis = eAxisType.AXIS_X; break;
case "Y": axis = eAxisType.AXIS_Y; break;
default: return;
}
private void MoveCommandXYPos(eAxisType axisType)
if (_PublishMode) {
PublishMotion(new ScannerCommandMoveParam {
Kind = eScannerMoveKind.CommandPos,
Axis = axis,
CommandPosX = CommandPosX,
CommandPosY = CommandPosY,
Mode = _MotionMode,
VelocityRate = _VelocityRate,
});
return;
}
MoveCommandXYPos(axis, CommandPosX, CommandPosY, _MotionMode, _VelocityRate);
}
private void MoveCommandXYPos(eAxisType axisType, double commandPosX, double commandPosY, eMotionMode motionMode, double velocityRate)
{
double xPos = 0.0;
double yPos = 0.0;
switch (axisType) {
case eAxisType.AXIS_X: {
xPos = CommandPosX;
xPos = commandPosX;
if (_MotionMode == eMotionMode.ABS) {
if (motionMode == eMotionMode.ABS) {
yPos = _Scanner.GetCommandPosY();
} else {
yPos = 0.0;
@ -309,13 +395,13 @@ namespace WeldingCommon.Views
break;
case eAxisType.AXIS_Y: {
if (_MotionMode == eMotionMode.ABS) {
if (motionMode == eMotionMode.ABS) {
xPos = _Scanner.GetCommandPosX();
} else {
xPos = 0.0;
}
yPos = CommandPosY;
yPos = commandPosY;
}
break;
@ -324,7 +410,7 @@ namespace WeldingCommon.Views
}
// 현재 Curruent Pos 기준으로, 입력방향으로 입력값이 Limit 를 넘어 서는지 확인
if (_MotionMode == eMotionMode.ABS) {
if (motionMode == eMotionMode.ABS) {
if (CheckOverLimitPos(xPos, yPos)) {
Log.E(string.Format("움직이려는 범위가 스캐너 범위를 초과하였습니다."));
return;
@ -336,10 +422,10 @@ namespace WeldingCommon.Views
}
}
double finalVelocity = _ScannerConfig.JumpSpeed * _VelocityRate;
double finalVelocity = _ScannerConfig.JumpSpeed * velocityRate;
_Scanner.SetJumpSpeedCtrl(finalVelocity);
if (_MotionMode == eMotionMode.ABS) {
if (motionMode == eMotionMode.ABS) {
_Scanner.JumpAbs(xPos, yPos);
} else {
_Scanner.JumpRel(xPos, yPos);

View File

@ -30,6 +30,7 @@
<RowDefinition Height="auto"></RowDefinition>
<RowDefinition Height="auto"></RowDefinition>
<RowDefinition Height="auto"></RowDefinition>
<RowDefinition Height="auto"></RowDefinition>
</Grid.RowDefinitions>
<GroupBox Header="Scout Image" Grid.Row="0" Margin="0 3 0 0" Style="{StaticResource StGroupBox2nd}">
@ -153,7 +154,14 @@
</Grid>
</GroupBox>
<GroupBox Header="Import Object From DXF" Grid.Row="2" Margin="0 5 0 0" Style="{StaticResource StGroupBox2nd}">
<Button Grid.Row="2" Margin="0 5 0 0" HorizontalAlignment="Right" Padding="8 3" Command="{Binding CMD_ToggleScannerCamView}" Style="{StaticResource StButtonAccentBlue}">
<StackPanel Orientation="Horizontal">
<Image Width="20" Height="10" Margin="5 0 5 0" Source="../../Resource/guide.png"/>
<TextBlock Text="Scanner Cam"/>
</StackPanel>
</Button>
<GroupBox Header="Import Object From DXF" Grid.Row="3" Margin="0 5 0 0" Style="{StaticResource StGroupBox2nd}">
<Button Margin="5" Command="{Binding CMD_ImportObjectFromDXF}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Height="auto" Style="{StaticResource StButtonAccentBlue}">
<StackPanel Orientation="Horizontal">
<Image Width="20" Height="10" Margin="5 0 5 0" Source="../../Resource/dxf.png"/>

View File

@ -44,6 +44,7 @@ namespace WeldingCommon.Views
public ICommand CMD_LaserGuideBeam { get; }
public ICommand CMD_LaserReadyCheck { get; }
public ICommand CMD_ImportObjectFromDXF { get; }
public ICommand CMD_ToggleScannerCamView { get; }
private readonly IAuroraEnvironment _Aurora;
private SystemParameter _SystemParameter;
@ -102,6 +103,9 @@ namespace WeldingCommon.Views
private SequenceDialog _SequenceDialog;
// 레시피 화면에서 토글로 띄우는 스캐너 카메라 + 모션 창 (재사용을 위해 Hide/Show)
private WDScannerCamMotion _ScannerCamWindow;
private WinSequenceDiagram _WinSequenceDiagramScoutNormal;
private WinSequenceDiagram _WinSequenceDiagramScoutFlyingVision;
private WinSequenceDiagram _WinSequenceDiagramTeachingRun;
@ -139,6 +143,7 @@ namespace WeldingCommon.Views
CMD_LaserGuideBeam = new DelegateCommand(PerformCmdLaserGuideBeam);
CMD_LaserReadyCheck = new DelegateCommand(PerformCmdLaserReadyCheck);
CMD_ImportObjectFromDXF = new DelegateCommand(PerformCmdImportObjectFromDXF);
CMD_ToggleScannerCamView = new DelegateCommand(PerformCmdToggleScannerCamView);
_Aurora.EventObservable.AsObservable(AuroraSysEvent.SYS_SETTING_CHANGED)
.ObserveOnDispatcher()
@ -511,6 +516,22 @@ namespace WeldingCommon.Views
}
}
private void PerformCmdToggleScannerCamView(object obj)
{
if (_ScannerCamWindow == null) {
_ScannerCamWindow = new WDScannerCamMotion(_Aurora, CommonDeviceIds.Scanner) {
Owner = Application.Current.MainWindow,
};
}
if (_ScannerCamWindow.IsVisible) {
_ScannerCamWindow.Hide();
} else {
_ScannerCamWindow.Show();
_ScannerCamWindow.Activate();
}
}
private void PerformCmdImportObjectFromDXF(object obj)
{
System.Windows.Forms.FileDialog dlg = new System.Windows.Forms.OpenFileDialog();

View File

@ -0,0 +1,24 @@
<Window x:Class="WeldingCommon.Views.WDScannerCamMotion"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WeldingCommon.Views"
mc:Ignorable="d"
Name="uc"
Background="#FF2D2D30"
Width="1100" Height="700"
WindowStartupLocation="CenterOwner"
Title="Scanner Cam / Motion"
IsVisibleChanged="Window_IsVisibleChanged">
<Grid Margin="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.3*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<!-- 카메라 라이브 뷰 (전용 displayType: Machine_C) -->
<Grid Grid.Column="0" Margin="5" x:Name="gridScannerCameraView"/>
<!-- 매뉴얼 스캐너 모션 컨트롤 재사용 (publish 모드 VM 주입) -->
<local:UCScannerMotion Grid.Column="1" Margin="5" x:Name="motionView"/>
</Grid>
</Window>

View File

@ -0,0 +1,64 @@
using Aurora;
using System;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Threading;
using VisionCommon;
using VisionUI;
using VisionUI.Camera;
namespace WeldingCommon.Views
{
/// <summary>
/// 레시피 화면에서 토글로 띄우는 "스캐너 카메라 + 모션" 창.
/// - 카메라: 매뉴얼 화면(Machine_B)과 충돌하지 않도록 전용 displayType(Machine_C)로 별도 인스턴스 생성.
/// - 모션: 매뉴얼 UCScannerMotion 을 그대로 재사용하되, publish 모드 VM 을 주입해
/// 버튼 입력값을 이벤트(EVENT_SCANNER_COMMAND_MOVE)로 발행 → 매뉴얼 측 VM 이 실제 이동 수행.
/// - 닫기: Dispose 하지 않고 Hide → 언제든 다시 Show 가능.
/// </summary>
public partial class WDScannerCamMotion : Window
{
private const eDisplayType DisplayType = eDisplayType.Machine_C;
private delegate void HideWindow();
public WDScannerCamMotion(IAuroraEnvironment aurora, string scannerId)
{
InitializeComponent();
motionView.DataContext = new UCScannerMotionViewModel(aurora, scannerId, publishMode: true);
CreateScannerCameraView();
}
private void CreateScannerCameraView()
{
if (gridScannerCameraView.Children.Count == 0) {
var fovInfo = VisionRecipeManager.GetFovInfos().FirstOrDefault();
if (fovInfo != null) {
var cameraView = IpViewManager.CreateCameraView(fovInfo, DisplayType);
gridScannerCameraView.Children.Add(cameraView);
}
}
}
private void Window_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
bool isVisible = (bool)e.NewValue;
if (isVisible) {
CameraUcManager.Instance.StartLiveAll(DisplayType);
} else {
CameraUcManager.Instance.StopLiveAll();
}
}
protected override void OnClosing(CancelEventArgs e)
{
// 닫기 버튼/Alt+F4 시에도 파괴하지 않고 숨김 처리(재사용 유지).
CameraUcManager.Instance.StopLiveAll();
e.Cancel = true;
Dispatcher.BeginInvoke(DispatcherPriority.Normal, new HideWindow(() => this.Hide()));
}
}
}

View File

@ -385,6 +385,9 @@
</Compile>
<Compile Include="Views\Recipes\UCScannerObjectTeachingControlBoxViewModel.cs" />
<Compile Include="Views\Recipes\UCScannerObjectTeachingViewModel.cs" />
<Compile Include="Views\Recipes\WDScannerCamMotion.xaml.cs">
<DependentUpon>WDScannerCamMotion.xaml</DependentUpon>
</Compile>
<Compile Include="Views\Test\UCTestDevice.xaml.cs">
<DependentUpon>UCTestDevice.xaml</DependentUpon>
</Compile>
@ -704,6 +707,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\Recipes\WDScannerCamMotion.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\Test\UCTestDevice.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>

View File

@ -0,0 +1,186 @@
# 레시피 화면에서 기존 스캐너 모션 + 카메라 뷰 재사용 가능성 분석
> 작성 2026-06-23. 목적: 레시피 티칭 화면(`UCScannerObjectTeaching`)에서
> **기존 매뉴얼 화면의 스캐너 모션 컨트롤(`UCScannerMotion`) + 카메라 라이브 뷰(`CameraView`)** 를
> 재사용해, "스캐너를 원하는 위치로 이동시켜 카메라로 보면서 티칭"하는 워크플로를
> 한 화면에서 돌릴 수 있는지 판정한다.
> **이 문서는 분석 전용 — 소스 변경 없음.**
---
## 0. 핵심 결론 (먼저)
| 재사용 대상 | 재사용 가능성 | 핵심 제약 |
|---|---|---|
| **스캐너 모션 컨트롤** `UCScannerMotion` (+VM) | ✅ **그대로 재사용 가능** | 생성 시 `scannerId` 주입 필요 / 모니터링 타이머 On·Off |
| **카메라 라이브 뷰** `CameraView` | ✅ 가능 (별도 인스턴스) | WPF 단일 부모 / `displayType` 전역 등록 충돌 |
→ **둘 다 재사용 가능**. 단, "복붙"이 아니라 **VM을 새로 물려주고(또는 공유) toggle Window/패널로 띄우는** 방식이어야 안전하다. 자세한 권장안은 6장.
---
## 1. 요구 워크플로
레시피 티칭 화면에서:
1. 작은 버튼으로 **CAM 라이브 뷰**를 띄운다 (언제든 띄웠다 닫았다).
2. `CommandPosX/Y` 입력 + `Move X`/`Move Y`(`CMD_CommandPosMove`, 파라미터 `"X"`/`"Y"`)로
스캐너를 명령 위치로 이동.
3. 이동 결과를 카메라로 눈으로 확인하며 객체 위치를 티칭.
즉 **이동(모션) + 관찰(카메라)** 두 기능이 한 화면에 필요하다.
카메라만 띄우면 *보기만* 되고 *움직이기*는 안 된다.
---
## 2. 현재 구조 (출처 파일)
### 2.1 매뉴얼 스캐너 화면 (재사용 원본)
- `Views/Manual/Scanner/UCManualScannerOperation.xaml(.cs)` — 컨테이너
- 좌측: `gridScannerCameraView` 에 `CameraView`(displayType=`Machine_B`) 코드비하인드로 add
(`UCManualScannerOperation.xaml.cs:38-47` `CreateScannerCameraView()`)
- 가시성 토글 시 `CameraUcManager.Instance.StartLiveAll(Machine_B)` / `StopLiveAll()`
(`UCManualScannerOperation.xaml.cs:49-57`)
- 우측 상단: `UCScannerMotion` (`UCManualScannerOperation.xaml:59`)
- VM: `UCManualScannerOperationViewModel`
- `ScannerMotionViewModel = new UCScannerMotionViewModel(_Aurora, scannerId)`
(`UCManualScannerOperationViewModel.cs:40`)
- 생성자 시그니처가 `(aurora, scannerId, isUseMarkingOntheFly)` — **scannerId를 외부에서 주입받음**
### 2.2 스캐너 모션 컨트롤 (재사용 핵심)
- `Views/Manual/Scanner/UCScannerMotion.xaml(.cs)` + `UCScannerMotionViewModel.cs`
- 보유 기능 전부 VM 자체에 캡슐화되어 있음:
- `CommandPosX/Y`, `Move X/Y` → `CMD_CommandPosMove("X"/"Y")` → `MoveCommandXYPos()`
→ `_Scanner.JumpAbs/JumpRel` (`UCScannerMotionViewModel.cs:280-350`)
- Jog(▲▼◀▶/Home), Step, Speed(Low/Mid/High), Mode(ABS/REL)
- X/Y Limit 체크(`CheckOverLimitPos`), 현재좌표/Busy 모니터링(100ms DispatcherTimer)
- **안전장치 내장**: 양산(`SeqAutoRun.Running`) 중 Jog 차단(`:193-195`), Limit 초과 차단
- 의존성: `IAuroraEnvironment` + `scannerId` 뿐. `_Scanner`, `_Laser`, `_ScannerConfig`를
`DeviceManager`에서 꺼냄(`:90-92`). → **레시피 VM 환경에서도 동일하게 확보 가능.**
### 2.3 카메라 라이브 뷰
- `IpViewManager.CreateCameraView(fovInfo, displayType)` 가 `new CameraView` 반환(`IpViewManager.cs:34-37`)
- `CameraView` 생성자가 전역 등록 수행(`MilUI/Camera/CameraView.xaml.cs:58-59`):
```csharp
CameraViewManager.Instance.SetCameraView(displayType, fovInfo, this);
CameraDisplayManager.SetDisplay(displayType, fovInfo, milCameraDisplay);
```
- `CameraDisplayManager`는 `(displayType, fovInfo)`당 **디스플레이 1개만 보관**(`CameraDisplayManager.cs:23-25, 32-48`)
- 라이브 On/Off: `CameraUcManager.Instance.StartLiveAll(displayType)` / `StopLiveAll()`
(`CameraUcManager.cs:134-157`) — 내부적으로 `GetDisplay(fovInfo, displayType)`로 디스플레이를 찾음
### 2.4 레시피 티칭 화면 (재사용 대상지)
- `Views/Recipes/UCScannerObjectTeaching.xaml(.cs)` + `...ViewModel.cs`
- 좌측 300px: `UCScannerObjectTeachingControlBox`
- 우측: `WindowsFormsHost` → LASA `MainPage` (WinForms 티칭 화면)
- 그리드 전체 `IsEnabled="{Binding IsManualStatus}"` (매뉴얼 모드 게이팅)
- VM: `UCScannerObjectTeachingViewModel(IAuroraEnvironment aurora)` — **현재 scannerId 안 받음**
- `IsManualStatus`를 `EVENT_EQUIPMENT_STATUS_CHANGE`로 갱신(`:75-80`) → 모션 게이팅에 그대로 활용 가능
---
## 3. 재사용 가능성 판정
### 3.1 스캐너 모션 컨트롤 — ✅ 그대로 재사용 가능
- `UCScannerMotion`은 **UI/로직이 VM에 완결**되어 있고 외부 의존이 `aurora + scannerId` 뿐이라
레시피 화면에 `UCScannerMotion`을 그대로 배치하고 DataContext에
`new UCScannerMotionViewModel(aurora, scannerId)`만 물려주면 동작한다.
- 안전 인터락(양산 중 Jog 차단, Limit 체크)이 VM에 내장 → 레시피 화면에서 써도 동일하게 보호됨.
- **확보해야 할 것: `scannerId`**
- 매뉴얼 화면은 등록 시점(`EquipmentSetup`)에서 scannerId를 받아 넘긴다.
- 레시피 VM은 현재 안 받으므로, ① 레시피 VM 생성자에 scannerId 파라미터 추가하거나
② `DeviceManager`에서 Scanner 디바이스 id를 조회해 얻으면 된다(매뉴얼과 동일 값 사용).
### 3.2 카메라 라이브 뷰 — ✅ 가능(단, 별도 인스턴스 + 전용 displayType)
- **제약 A (WPF 단일 부모):** `gridScannerCameraView`에 들어간 매뉴얼 화면의 `CameraView`
인스턴스를 레시피 화면에 동시에 붙일 수 없다(UIElement 부모는 1개). → 레시피용으로
`CreateCameraView`를 다시 호출해 **새 인스턴스**를 만들어야 한다.
- **제약 B (displayType 전역 등록 충돌):** 새 인스턴스를 매뉴얼과 같은 `Machine_B`로 만들면
생성자의 `CameraDisplayManager.SetDisplay(Machine_B, fov, ...)`가 매뉴얼 등록을 **덮어쓴다**.
이후 매뉴얼 화면의 `StartLiveAll(Machine_B)`가 엉뚱한 디스플레이를 잡을 위험.
→ 레시피 팝업은 **전용 displayType**을 써야 한다.
- `eDisplayType` enum에 `Machine_C`가 **정의만 되고 현재 미사용**(`CamCommon.cs:22`, 전 코드 검색상 참조 0건)
→ 레시피 팝업 전용으로 적합.
- **라이브 On/Off:** 매뉴얼 화면처럼 띄울 때 `StartLiveAll(Machine_C)`, 닫을 때 `StopLiveAll()`을
**직접 호출**해야 한다(자동 아님).
---
## 4. 리스크 / 주의
| # | 리스크 | 완화 |
|---|---|---|
| R1 | 같은 `Machine_B`로 카메라뷰 2개 생성 시 디스플레이 등록 덮어쓰기 | 레시피는 `Machine_C` 전용 사용 |
| R2 | 같은 CameraView 인스턴스 2화면 공유 시도(부모 충돌 예외) | 인스턴스 분리 생성 |
| R3 | 모션 VM 2개(매뉴얼/레시피)가 같은 `_Scanner`를 동시 제어 | 둘은 다른 탭이라 동시 표시 안 됨 + Busy/양산 인터락 내장. 단 VM 공유로도 회피 가능(6.C) |
| R4 | 모니터링 타이머 누수 | `UCScannerMotionViewModel`은 `CMD_VisibleChanged`로 타이머 Start/Stop(`:113-122`). 레시피에서도 `IsVisibleChanged`/패널 토글에 연결 필요 |
| R5 | scannerId 불일치 | 매뉴얼과 동일한 scannerId 사용(DeviceManager 조회 또는 동일 주입) |
| R6 | 카메라뷰 Dispose 누락으로 자원 점유 | 화면 종료/탭 이탈 시 `IpViewManager.DisposeCameraView` 호출 권장 |
LASA `MainPage`(WindowsFormsHost)와 WPF 카메라 팝업의 공존은 **별도 top-level Window**로
띄우면 airspace 문제 없음. 패널 인라인(같은 WPF 트리) 방식도 가능.
---
## 5. scannerId 확보 경로 (참고)
- 매뉴얼 경로: `EquipmentSetup` 등록 → `UCManualScannerOperationViewModel(aurora, scannerId, ...)`
→ `UCScannerMotionViewModel(aurora, scannerId)`.
- 레시피 경로(택1):
1. 레시피 VM 생성자에 `scannerId` 추가(등록부에서 매뉴얼과 동일 값 전달) — 명시적·안전
2. `aurora.DeviceManager`에서 Scanner 타입 디바이스 id 조회 — 등록부 변경 불필요
---
## 6. 권장 구현안 (셋 중 택1)
### A. 카메라만 (최소, 위험 최저)
- 레시피 컨트롤박스에 작은 버튼 → 전용 toggle Window에 `CameraView(Machine_C)` 1회 생성·캐시,
Show/Hide. Show 시 `StartLiveAll(Machine_C)`, Hide/Close 시 `StopLiveAll()`.
- Close는 `e.Cancel=true; Hide()` 패턴(프레임워크 `CameraViewWindow.OnClosing`과 동일)으로 재사용 유지.
- 한계: 이동은 매뉴얼 화면에서 따로 해야 함. "보기 전용".
### B. 카메라 + 모션을 한 팝업에 (요구 워크플로 완성) ← 추천
- toggle Window 안에 `UCScannerMotion`(DataContext=`new UCScannerMotionViewModel(aurora, scannerId)`)
+ `CameraView(Machine_C)`를 좌우로 배치.
- Window 가시성에 모션 VM의 `CMD_VisibleChanged`(타이머)와 카메라 라이브 On/Off를 함께 연결.
- 이 한 곳에서 이동→관찰 루프 완성. R3는 탭 분리 + 인터락으로 사실상 무해.
### C. 모션 VM 공유(인라인 패널)
- 레시피 VM이 `UCScannerMotionViewModel`을 프로퍼티로 보유(매뉴얼과 동일 인스턴스 패턴),
레시피 화면에 `UCScannerMotion`을 인라인 배치 + 카메라는 별 패널/팝업.
- 장점: 모션 제어 주체가 명확(중복 제어 원천 차단). 단 레시피 VM 변경 폭이 가장 큼.
---
### D. 이벤트 구동 — 모션 로직 재사용 (모션 부분 최선) ← 채택 검토
> 사용자 제안. "레시피엔 입력+버튼만, 누르면 Aurora 이벤트 발행 → 구독 중인 매뉴얼
> `UCScannerMotionViewModel`이 같은 메서드를 호출"한다.
- **성립 근거(검증됨):** 매뉴얼 스캐너 View/VM은 `EquipmentSetup.RegisterViews()`에서
**앱 기동 시 1회 즉시 생성**되어 프로세스 내내 생존
(`Machines/ILSOS_PrismaticRD_PTOP/EquipmentSetup.cs:290`).
→ 레시피 탭에 있어도 매뉴얼 VM의 이벤트 구독이 살아있어 발행이 도달함.
Aurora `EventObservable`/`EventPublisher`는 전역 pub/sub(레시피 VM도 이미 다수 구독, `...ViewModel.cs:55-69`).
- **장점:** 모션 로직(Move/Limit/양산중 차단 인터락) 무중복 재사용, 레시피 측은 입력+버튼+발행만.
직접 참조 없이 느슨한 결합.
- **반드시 보완할 3가지:**
1. **페이로드 필수.** 현재 `MoveCommandXYPos()`는 `CommandPosX/Y`·`_MotionMode`·`_VelocityRate`를
**매뉴얼 VM 자기 state에서** 읽음(`UCScannerMotionViewModel.cs:301,303,339`).
레시피에서 이벤트만 쏘면 매뉴얼 VM의 옛 값으로 움직임 → 이벤트 `Param`에
`{axis, posX, posY, mode, speed}`를 담고, 매뉴얼 측은 **인자형 오버로드**
`MoveCommandXYPos(axis, x, y, mode, speed)`를 호출(기존 `CMD_CommandPosMove`는 UI 전용 유지).
2. **스레드.** 구독은 UI 스레드 보장 없음 → 매뉴얼 측 구독에 `.ObserveOnDispatcher()` 권장.
3. **숨은 결합.** "매뉴얼 VM 항상 생존"(즉시생성)에 의존. 추후 지연 생성 전환 시 레시피 버튼이
조용히 무반응. → 이벤트 상수 정의부 주석으로 의존 명시 + 무응답 로그 가드.
- **범위 한계:** 이벤트 트릭은 **모션 한정**. **카메라 뷰는 별개**(옵션 A 방식: `Machine_C` 전용
인스턴스 + 라이브 On/Off 직접 구동).
## 7. 최종 판단
- **기술적으로 전면 재사용 가능.**
- **모션은 옵션 D(이벤트 구동) 채택 검토** — 매뉴얼 VM이 즉시생성으로 항상 생존함이 확인되어
성립하며, scannerId 주입이나 모션 VM 중복 없이 로직을 그대로 재사용한다.
(3가지 보완: 이벤트 페이로드, `ObserveOnDispatcher`, 숨은 결합 가드)
- **카메라는 옵션 A 방식** — 전용 `Machine_C`로 새 인스턴스 생성 + 라이브 On/Off 직접 구동.
- 후속 작업: ① 이벤트 상수 + 페이로드 타입 정의 → ② 매뉴얼 측 인자형 오버로드 분리 + 구독 추가
→ ③ 레시피 측 입력/버튼/발행 + 카메라 토글(Machine_C) 구현.